Verdicts and reasons
How the valid, invalid or review verdict is computed from reason severities, the full table of stable reason codes and what to do in each case.
The verdict answers one specific question: "is this document a valid X according to my rules?". It appears in
verdict when you state what you expect with expect. Without expect, verdict is null and you only get the
classification (document), the fields (fields), the deterministic checks (checks) and the signals
(warnings).
The verdict object
"verdict": {
"expected": ["es_dni"],
"match": true,
"status": "valid",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Spanish ID card (DNI)." },
{ "code": "not_expired", "severity": "info", "message": "Valid until 12/03/2031." }
]
}| Field | Type | Description |
|---|---|---|
expected | string[] | The types you asked for in expect, always as a list. |
match | boolean | true if the detected type (document.type) is one of the expected ones. |
status | valid | invalid | review | The decision. |
reasons | object[] | Every rule evaluated, with a stable code, a severity and a human-readable message. |
Each reason looks like this:
| Field | Type | Description |
|---|---|---|
code | string | Stable code. Write your logic against it. |
severity | info | warning | error | info = the rule holds; warning = it cannot be confirmed; error = the rule fails. |
message | string | Text for people in the language you chose (es, en, pt, fr). Wording may change: do not compare it as a string. |
The three statuses
| Status | When |
|---|---|
| Válido | No reason has severity warning or error. |
| No válido | At least one reason has severity error: different type, expired, holder mismatch, wrong NIF check letter, wrong amount… |
| Revisar | No error, but at least one warning: low quality, confidence below the type's threshold, a value that could not be read… |
The rule is mechanical and always the same:
if any reason has severity "error" → invalid
else if any has severity "warning" → review
else → validerror beats warning: an expired DNI that is also blurry is invalid, not review.
review is not an API failure. It means Constaia does not have enough information to decide safely, and a person
should look at it or you should ask for another photo. See the human review guide.
Which reasons appear
- Type: always exactly one of
type_match,type_mismatchortype_unknown. - Checks you asked for (and those on by default, such as
not_expiredfor documents with an expiry date): one or more reasons each. Details in checks. - Type-specific rules:
not_fit_for_sport(medical certificate saying "not fit"),has_records(criminal record certificates). - Failed deterministic checks: if an item in
checks[]haspassed: false, a reason with the samecodeand severityerroris added. Passing checks only appear inchecks[], not inreasons. - Quality and confidence:
low_qualityandlow_confidence, with severitywarning. - PDF: the electronic signature result (
signature_valid,signature_invalid…) andedited_suspectedwhen the metadata points to editing. See digital signatures in PDF. - Form I-9:
i9_list(info) on US documents from lists A, B or C.
Reason code table
These codes are stable. We may add new codes without notice (see versioning): treat
any unknown code according to its severity.
| Code | Possible severities | When it appears |
|---|---|---|
type_match | info | The detected type is one of those in expect. |
type_mismatch | error | Another catalogue type was detected with confidence. |
type_unknown | warning | The type is not recognised (generic) or classification confidence is below 0.5. |
not_expired | info, warning, error | Validity. warning if the expiry date could not be read. |
max_age_days | info, warning, error | Age of the issue date. warning if there is no issue date. |
age | info, warning | Age within the limits you set (info) or unreadable birth date (warning). |
min_age_years | error | The holder is younger than min_age_years. |
max_age_years | error | The holder is older than max_age_years. |
holder | info, warning, error | Match against the holder data. warning if the document does not show that value. |
required_field_missing | error | A field from require_fields is missing (one reason per field). |
require_signature | info, error | Signature present or missing. |
require_stamp | info, error | Stamp present or missing. |
expected_amount | info, error | Amount (amount, or total for invoice) equal to or different from the expected one. |
expected_iban | info, error | The expected IBAN appears, or not, in the document. |
expected_reference | info, error | The expected reference appears, or not, in reference or concept. |
not_fit_for_sport | error | Sports medical certificate stating "not fit". |
has_records | info, error | Criminal record certificate: no records (info) or records (error). |
low_quality | warning | Blurry, cropped, glare, photo of a screen, photocopy, possible editing or several documents. |
low_confidence | warning | Overall confidence (classification and key fields) is below the type's threshold. |
nif_check_digit | error | Wrong check letter in a DNI, NIE or CIF. |
mrz_checksums | error | Wrong MRZ check digits. |
mrz_matches_visual | error | The MRZ does not match the printed data. |
iban_checksum | error | IBAN with wrong check digits. |
invoice_totals | error | Invoice base, VAT, withholding and total do not add up. |
csv_format | error | The secure verification code (CSV) does not have a valid format. |
date_consistency | error | Inconsistent dates (birth after issue, issue after expiry…). |
id_number_checksum, id_number_format | warning, error | A national identifier fails its check digit or format. warning for identifiers marked as soft in the catalogue. |
id_number_matches_birth_date | error | The birth date encoded in the identifier doesn't match the printed one. |
aamva_matches_visual | error | The PDF417 barcode on the back of a US/Canadian license or ID doesn't match the front. |
signature_valid | info | The PDF carries an intact, trusted electronic signature. See digital signatures in PDF. |
signature_invalid | error | The PDF signature is broken: the content doesn't match what was signed. |
signature_missing | warning, error | The PDF has no electronic signature (for types that are usually signed), or require_valid_signature was requested and it is not a signed PDF. |
document_modified_after_signing | warning, error | The PDF was modified after being signed. error with require_valid_signature. |
untrusted_signer | warning, error | The signature is intact but the certificate doesn't chain to the trust list. error with require_valid_signature. |
edited_suspected | warning | The PDF metadata points to an editor (iLovePDF, Word, Canva…) or to later changes. |
i9_list | info | US document acceptable for Form I-9: tells you the list (A, B or C). |
Same code, different severity
The code tells you which rule was evaluated; the severity tells you how it went. A valid DNI and an
expired one produce the same code:
{ "code": "not_expired", "severity": "info", "message": "Valid until 12/03/2031." }{ "code": "not_expired", "severity": "error", "message": "Expired on 15/06/2020." }So your code must always look at the code + severity pair, never just at whether the code is present.
Signals (warnings)
warnings is a list of signals about the image or the document. They are not proof of fraud: they are hints to
decide whether to ask for another photo or review by hand. Constaia is not biometric KYC and does no face matching.
| Code | Meaning |
|---|---|
low_quality | Overall low quality. |
blurry | Blurry image. |
cropped | The document is cropped. |
glare | Glare hides data. |
screen_photo_suspected | Possible photo of a screen. |
photocopy_suspected | Possible photocopy. |
edited_suspected | Possible digital edit. |
multiple_documents | More than one document in the file. |
side_missing | One side is missing. |
language_mismatch | The language is not the expected one for the type. |
Warnings are signals, not proof of authenticity.
The quality signals (low_quality, blurry, cropped, glare, screen_photo_suspected, photocopy_suspected,
edited_suspected, multiple_documents) are summarised in a single low_quality reason with severity warning, so
they push the verdict to review (unless there is an error). side_missing and language_mismatch appear in
warnings but do not change the verdict on their own.
"verdict": {
"expected": ["es_dni"],
"match": true,
"status": "review",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Spanish ID card (DNI)." },
{ "code": "not_expired", "severity": "info", "message": "Valid until 12/03/2031." },
{ "code": "low_quality", "severity": "warning", "message": "Image quality is insufficient (blurry, low_quality)." }
]
},
"warnings": ["blurry", "low_quality"]If the image is so bad that it is rejected before OCR, document is null, match is false, the only reason is
low_quality (warning) and the analysis is not charged.
What to do with each verdict
| Verdict | Recommended action |
|---|---|
valid | Accept automatically. Store the id and the fields you need. |
invalid | Reject and show the user the message of reasons with severity: "error" so they upload another document. |
review | Accept provisionally or ask for another photo, and queue it for human review. |
Also remember the analysis may not be finished: with async: true, or if it takes longer than 30 s, you get 202
with status: "queued" or "processing" and the result arrives by webhook.
import { Constaia, InvalidRequestError } from "@constaia/sdk";
import { fromPath } from "@constaia/sdk/node";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
type Decision =
| { action: "accept"; documentNumber: string | undefined }
| { action: "reject"; errors: string[] }
| { action: "review"; warnings: string[] }
| { action: "pending"; id: string };
export async function checkDni(path: string, fullName: string): Promise<Decision> {
const analysis = await constaia.analyze(await fromPath(path), {
expect: "es_dni",
checks: { holder: { fullName } },
language: "en",
});
if (analysis.status !== "completed") {
return { action: "pending", id: analysis.id }; // will arrive by webhook
}
const reasons = analysis.verdict?.reasons ?? [];
switch (analysis.verdict?.status) {
case "valid":
return { action: "accept", documentNumber: analysis.fields.document_number?.value as string | undefined };
case "invalid":
return {
action: "reject",
errors: reasons.filter((r) => r.severity === "error").map((r) => r.message),
};
default:
return {
action: "review",
warnings: reasons.filter((r) => r.severity === "warning").map((r) => r.message),
};
}
}
try {
console.log(await checkDni("./dni_valid.jpg", "María García López"));
} catch (err) {
if (err instanceof InvalidRequestError) console.error(err.code, err.param, err.message);
else throw err;
}React to specific codes when you need to
You can refine the decision per code. For example, on not_expired with error ask for a document that is still
valid; on holder with error, say the document does not belong to the registered person. For any code you do not
know, decide by its severity only.
Several accepted types
With expect as a list, match is true if the document is any of them. This is the usual setup for "identity
document": DNI, NIE/TIE or passport.
{ "file_url": "https://example.com/nie.jpg", "expect": ["es_dni", "es_nie", "passport"], "language": "en" }"document": { "type": "es_nie", "label": "Spanish foreigner ID (NIE / TIE)", "confidence": 0.97, "side": "both", "country": "ESP" },
"verdict": {
"expected": ["es_dni", "es_nie", "passport"],
"match": true,
"status": "valid",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Spanish foreigner ID (NIE / TIE)." },
{ "code": "not_expired", "severity": "info", "message": "Valid until 30/11/2029." }
]
}Checks are evaluated against the detected type, so look at document.type to know which field holds the number
(document_number for DNI and passport, nie_number for NIE). You can look up the fields of each type with
GET /v1/document-types/{type} or in the catalogue.
Without expect: verdict is null
If you don't send expect, Constaia classifies and extracts, but does not decide:
"document": { "type": "generic", "label": "Other document", "confidence": 0.64, "side": null, "country": null },
"verdict": null,
"checks": [],
"warnings": []Use it when you don't know which document will be uploaded and want to route on document.type, or when you apply
your own rules to fields. If you only need the type, POST /v1/classify costs 0.2
credits; its verdict only contains type_match or type_mismatch.
Next steps
Webhooks
Receive signed events from Constaia: each event's body, Standard Webhooks signature verification in seven languages, retries, idempotency and testing.
Checks
Reference for every checks option (expiry, issue age, holder age, holder, signature, amounts) and the deterministic NIF, MRZ, IBAN and invoice validations.