ID in a sign-up form
Validate a DNI, NIE or passport in a sports registration (not expired, belonging to the registrant, minimum age) with the widget, Express or Laravel.
In a registration (a race, a licence, a camp) you want to check three things about the ID document: that it is a DNI, NIE or passport, that it has not expired and that it belongs to the person signing up. For adult events, also that the person is over the minimum age.
This guide builds the full flow: the widget on the page, your backend (Node/Express or PHP/Laravel) calling Constaia, and the decision based on the verdict.
The flow
Registration page Your backend Constaia
───────────────── ──────────── ────────
1. form data ───────────────────▶ saves the registration (draft)
2. <constaia-upload> ──file─────▶ POST /api/registrations/:id/document
loads name, ID number and birth date
from the registration ──Bearer ck_…───▶ POST /v1/analyze
decides on verdict.status ◀──────────── analysis
3. verdict on screen ◀──reduced JSON──The API key only exists in your backend. The browser sends the file to your endpoint, and your backend sets
expect and checks from the data it already has about the registration. Don't trust options sent by the client.
Analysis options
{
"expect": ["es_dni", "es_nie", "passport"],
"checks": {
"not_expired": true,
"min_age_years": 18,
"holder": {
"full_name": "María García López",
"document_number": "12345678Z",
"birth_date": "1990-05-14"
}
},
"storage": "none",
"language": "en",
"metadata": { "registration_id": "1234" }
}| Option | Why |
|---|---|
expect | You accept any of the three. Another recognised type (a driving licence, for example) gives invalid with type_mismatch; an unrecognised document gives review with type_unknown. |
checks.not_expired | Already on by default for types with an expiry date; setting it explicitly makes the intent clear. |
checks.min_age_years | Minimum age computed from the birth date on the document. Remove it for events without an age limit. |
checks.holder | Compares with what the person typed in the form. Ignores accents, accepts a different surname order and small typos. |
storage: "none" | The file is processed in memory and never stored. It is the default unless you changed your account default. |
metadata.registration_id | Lets you find the analysis in the dashboard or with GET /v1/analyses?metadata[registration_id]=1234. |
All checks are described in Checks.
Step 1: the widget on the page
The <constaia-upload> widget works as the upload field: camera on mobile, quality check before
sending and both sides of the ID merged into a single image. Point it to an endpoint in your backend that includes the
registration id.
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<form id="registration">
<!-- name, ID number, birth date… already saved as draft with id 1234 -->
<constaia-upload
endpoint="/api/registrations/1234/document"
expect="es_dni,es_nie,passport"
lang="en"
></constaia-upload>
<button type="submit" disabled>Confirm registration</button>
</form>
<script type="module">
const upload = document.querySelector("constaia-upload");
const submit = document.querySelector("#registration button");
upload.addEventListener("constaia:result", (event) => {
const status = event.detail.verdict?.status;
submit.disabled = status === "invalid";
});
</script>The widget shows the verdict status and the verdict.reasons messages returned by your backend. The expect attribute
is only a UI hint: your server decides. For React or Vue, see the widget guide.
Step 2: your backend
npm i @constaia/sdk express multerimport express from "express";
import multer from "multer";
import { Constaia, ConstaiaError } from "@constaia/sdk";
import { findRegistration, updateRegistration } from "./db.js";
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
const NEXT_STATUS = { valid: "confirmed", review: "pending_review", invalid: "document_rejected" };
app.post("/api/registrations/:id/document", upload.single("file"), async (req, res) => {
const registration = await findRegistration(req.params.id);
if (!registration) return res.status(404).json({ error: { message: "Registration not found." } });
if (!req.file) return res.status(400).json({ error: { message: "The document is missing." } });
let analysis;
try {
analysis = await constaia.analyze(
{ file: req.file.buffer, filename: req.file.originalname },
{
expect: ["es_dni", "es_nie", "passport"],
checks: {
notExpired: true,
minAgeYears: 18,
holder: {
fullName: registration.fullName,
documentNumber: registration.documentNumber,
birthDate: registration.birthDate, // "YYYY-MM-DD"
},
},
storage: "none",
language: "en",
metadata: { registration_id: String(registration.id) },
},
);
} catch (err) {
if (err instanceof ConstaiaError) {
console.error("Constaia", err.status, err.code, err.requestId);
return res.status(502).json({ error: { message: "We couldn't analyze the document. Please try again." } });
}
throw err;
}
// If the analysis takes longer than 30 s it arrives queued/processing with no verdict: the result comes by webhook.
const status = analysis.status === "completed" ? (analysis.verdict?.status ?? "review") : "review";
const documentNumber =
analysis.fields?.document_number?.value ?? analysis.fields?.nie_number?.value ?? null;
await updateRegistration(registration.id, {
status: NEXT_STATUS[status],
documentAnalysisId: analysis.id,
documentVerdict: status,
documentNumber,
});
res.json({
id: analysis.id,
status: analysis.status,
verdict: analysis.verdict,
warnings: analysis.warnings,
});
});
app.listen(3000);The NIE returns its number in nie_number; the DNI and the passport in document_number. That's why the code reads
both. The fields of each type are in the catalogue.
If you'd rather send the file with your own form instead of the widget, the backend is the same: it receives the file
field as multipart. More examples in the Express and Laravel
guides.
Step 3: decide on the verdict
| Verdict | What it means here | What to do |
|---|---|---|
| Válido | Accepted document, not expired, belongs to the registrant and meets the minimum age. | Confirm the registration. |
| No válido | Some reason with severity: "error". | Don't confirm. Show the messages and let them upload another document. |
| Revisar | Some reason with severity: "warning": blurry photo, low confidence, an unreadable holder field… | Accept provisionally and send it to a human review queue. |
Reasons you'll see in this case:
code | With severity: "error" |
|---|---|
type_mismatch | Not a DNI, NIE or passport. |
not_expired | Expired ("Expired on 15/06/2020."). |
holder | Name, number or birth date don't match the form. |
min_age_years | Younger than the minimum ("The holder is 16; the minimum is 18."). |
nif_check_digit, mrz_checksums, mrz_matches_visual | A deterministic validation fails: DNI check letter, check digits or MRZ different from the printed data. |
To tell the user what failed, use the messages of the non-informational reasons. They come in the language you asked
for with language:
const problems = analysis.verdict.reasons
.filter((reason) => reason.severity !== "info")
.map((reason) => reason.message);Don't reject review automatically: most are improvable photos of correct documents. How to build the queue is in
Human review, and each status is detailed in Verdicts.
Minors
For registrations of minors don't use min_age_years. You have two options:
- Guardian's document: validate the mother's, father's or guardian's DNI or NIE with
holderbuilt from the guardian's data in the form andmin_age_years: 18. - Minor's document: if the minor has a DNI or passport, validate it with
holderfrom their data and, if the category requires it,max_age_years(e.g.17for under-18 categories). If they are older than the maximum you get themax_age_yearsreason withseverity: "error".
To compute the age on a date other than today (e.g. 31 December of the season), use checks.reference_date: "2026-12-31".
What to store
Store only what you need to justify the decision:
analysis.id(to look it up later if you keep results; see below).verdict.statusand the date.- The validated document number (
document_numberornie_number).
You don't need to keep the image or the other fields. With storage: "none" Constaia doesn't store the file; the
extracted results are kept until you delete the analysis. If you don't want that either, add keep_results: false:
you get the response once and afterwards GET /v1/analyses/{id} returns 404. See
Analyze without storing.
Test it in test mode
With a ck_test_… key the result depends on the file name (it must be a real image or PDF). The widget keeps the
file name of the front side, so you can test from the page itself. Details in Test mode.
| File | Result |
|---|---|
dni_valid.jpg | DNI of MARÍA GARCÍA LÓPEZ, 12345678Z, born 1990-05-14, valid until 12/03/2031 → valid (if holder matches). |
dni_expired.jpg | DNI of JUAN PÉREZ SÁNCHEZ expired on 15/06/2020 → invalid with not_expired. |
blurry.jpg | Same DNI as dni_valid with blurry and low_quality warnings → review. |
nie.jpg | NIE of ANNA KOWALSKA, X1234567L → valid. |
passport.jpg | Passport of MARIA GARCIA LOPEZ, PAA123456 → valid. |
To get valid with holder, create the test registration with the dni_valid data: María García López,
12345678Z, 1990-05-14. With another name you get invalid and the reason
"Holder mismatch: full_name is “MARÍA GARCÍA LÓPEZ”, expected “Juan Pérez”.".
curl https://api.constaia.com/v1/analyze \
-H "Authorization: Bearer $CONSTAIA_API_KEY" \
-F file=@dni_expired.jpg \
-F 'options={"expect":["es_dni","es_nie","passport"],"checks":{"not_expired":true},"language":"en"}'Next steps
Use cases
End-to-end use-case guides for sign-up IDs, medical and LOPIVI certificates, receipts, invoices to Excel, batches, human review and US documents.
Sport medical certificate
Check that a sport medical certificate declares the athlete fit, is signed and stamped, belongs to the athlete and is not older than your window.