Constaia
Use-case guides

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.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

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

options.json
{
  "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" }
}
OptionWhy
expectYou 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_expiredAlready on by default for types with an expiry date; setting it explicitly makes the intent clear.
checks.min_age_yearsMinimum age computed from the birth date on the document. Remove it for events without an age limit.
checks.holderCompares 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_idLets 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.

registration.html
<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 multer
server.js
import 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

VerdictWhat it means hereWhat to do
VálidoAccepted document, not expired, belongs to the registrant and meets the minimum age.Confirm the registration.
No válidoSome reason with severity: "error".Don't confirm. Show the messages and let them upload another document.
RevisarSome 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:

codeWith severity: "error"
type_mismatchNot a DNI, NIE or passport.
not_expiredExpired ("Expired on 15/06/2020.").
holderName, number or birth date don't match the form.
min_age_yearsYounger than the minimum ("The holder is 16; the minimum is 18.").
nif_check_digit, mrz_checksums, mrz_matches_visualA 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:

reasons.js
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 holder built from the guardian's data in the form and min_age_years: 18.
  • Minor's document: if the minor has a DNI or passport, validate it with holder from their data and, if the category requires it, max_age_years (e.g. 17 for under-18 categories). If they are older than the maximum you get the max_age_years reason with severity: "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.status and the date.
  • The validated document number (document_number or nie_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.

FileResult
dni_valid.jpgDNI of MARÍA GARCÍA LÓPEZ, 12345678Z, born 1990-05-14, valid until 12/03/2031 → valid (if holder matches).
dni_expired.jpgDNI of JUAN PÉREZ SÁNCHEZ expired on 15/06/2020 → invalid with not_expired.
blurry.jpgSame DNI as dni_valid with blurry and low_quality warnings → review.
nie.jpgNIE of ANNA KOWALSKA, X1234567L → valid.
passport.jpgPassport 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”.".

Terminal
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

Sur cette page