Verification

Document verification

OCR, MRZ and barcode extraction, authenticity and tamper checks, and the country/type document matrix.

The ID_VERIFICATION module authenticates a government-issued identity document, extracts its fields, and outputs the document portrait so face match can compare it to the selfie. It runs inside the hosted flow: the applicant photographs the document (front, and back when required), and the engine does OCR, machine-readable-zone parsing, and authenticity analysis server-side.

Info

This is usually the first module in a KYC workflow — every other identity module (face match, NFC, proof of address risk-scoring) depends on data it produces: the document portrait, the extracted fields, or the MRZ used to derive the NFC chip key.

Supported document families

The engine treats these families as first-class types, each with its own classifier label and field template:

TypeNotes
passportPhoto page is read via the MRZ (TD3) plus visual OCR.
id_card / national IDGeneric card family. Legacy id_card accepts any card-family document.
citizenshipNepali citizenship certificate.
voter_idVoter ID card.
pan (PAN card)Recovered by post-classification refinement when needed.
drivers_licenseOften single-sided in some jurisdictions (see require_back_side).
residence_permitCard family.
disabilityNo trained template — runs a generic extraction (auto-detect fields + best-effort OCR text, clearly flagged).

Note

Aadhaar cards have no dedicated classifier class, so they first surface as national_id / id_card and are relabelled to aadhaar from OCR-text signals (the 4-4-4 grouped number, UIDAI captions). Indian PAN cards are told apart from Nepali ones by their AAAAA9999A number and reported as pan_card (India). This is display-level only.

What the engine extracts

Fields are read from the MRZ where present and from visual OCR otherwise:

  • Full name (and first / last where the document splits them)
  • Document number
  • Date of birth, date of expiry
  • Nationality / issuing country
  • The document portrait (for face match)
  • On non-Latin documents, a romanised / English read of the same fields (translated_fields)

MRZ parsing (passports and other TD1/TD3 documents) cross-checks the printed data against the MRZ check digits. Barcode / PDF417 data, where a document carries it, is decoded for the same fields.

Authenticity and tamper checks

Beyond reading the document, the engine runs forgery / tamper analysis (EXIF-rule checks plus TruFor tamper localisation on the hosted forensics engine). Two config keys control the outcome:

  • forgery_check — enable tamper analysis.
  • forgery_action — hold (default) routes a suspected forgery to manual review and gates the applicant at the document step (they see a "couldn't verify this document" screen and, if allow_bypass is on, a Continue-anyway option). flag does not gate the applicant — the document step can still approve so they proceed normally — but silently force-holds the whole session for review (session.metadata.background_review) so an operator sees the finding without tipping off a fraudster mid-flow.

Warning

A suspected forgery is never auto-declined by the client. Depending on forgery_action it either caps the module at review or is flagged for an operator — the authoritative outcome still comes from the webhook / decision endpoint. The forgery finding itself (document_forgery_suspected, exif_metadata_suspicious) is also stripped from the applicant-facing response either way — only the operator's console view shows it, so a genuine forger can't learn what tripped the check.

Restricting which documents are accepted

How scoring and decisioning works

ID_VERIFICATION never outputs a single numeric "pass score" the way a credit-risk model might — the terminal status is derived from a set of boolean checks plus a quality_score, evaluated in order:

Blocklist check first

The extracted document number is screened against typed lists. A block-action match on the document list type declines immediately, regardless of everything else.

Hard-fail checks

If any structural check fails (document not recognized, wrong type presented) or the document is expired with allow_expired off, the step declines.

Review-routing warnings

A defined set of non-fatal findings — MRZ checksum failure, no MRZ detected, MRZ↔visual mismatch, implausible dates, screen-capture suspected, no portrait detected, back side missing, forgery suspected (under hold), watchlist hit on a review-action list, issuing-country mismatch — route the step to review rather than an outright decline. These are treated as "OCR noise or a legitimate-but-unclear capture," not proof of fraud.

Quality floor

A capture whose overall quality_score falls below a low internal floor also routes to review even with no other warning — the read is judged too unreliable to trust either way.

Otherwise, approve

No blocklist hit, no hard-fail, no review warning, adequate quality → approved.

Note

An unreadable document — no OCR fields and no MRZ recovered at all (e.g. a genuinely out-of-model document type, or a truly unusable photo) — is deliberately not auto-declined. The engine can't tell "fraudulent" from "just unreadable," so it routes to review instead of penalizing a legitimate holder for a bad-model type or a poor photo.

Common decline / review reasons

Reason codeOutcomeWhat triggers it
ID_DOCUMENT_IN_BLOCKLISTDeclinedThe document number matches a block-action entry on a typed risk list
Type/structural mismatchDeclinedThe captured image doesn't match the declared document type or fails basic structural checks
Expired (with allow_expired: false)DeclinedExpiry date has passed and the workflow doesn't accept expired documents
document_forgery_suspected / exif_metadata_suspiciousReview (or session-held, under flag)Tamper analysis (TruFor / EXIF rules) flagged the capture
mrz_checksum_failed / no_mrz_detected / mrz_ocr_mismatchReviewMRZ check digits don't reconcile, or the MRZ is missing/unreadable, or MRZ and visual OCR disagree
no_portrait_detectedReviewNo usable face crop found on the document — blocks downstream face match
back_side_missingReviewA two-sided document type was captured front-only
document_type_not_allowedReviewThe presented type isn't in allowed_document_types / the country matrix
watchlist_hitReviewThe document number matched a review-action typed-list entry
list_fuzzy_matchReviewThe document number nearly matched an entry on a Fuzzy list (similarity at or above its threshold, below 100%) — data.list_fuzzy_matches names the list and score; a near-miss never declines
document_unreadableReviewNo identity fields and no MRZ recovered at all
Quality floor not metReviewCapture quality too low to trust the read either way

Warning

Common integration pitfalls.

  1. Setting allowed_document_types without also constraining country_documents can leave a mismatch where the console shows one thing and the flow enforces another — the country matrix always wins when both are set.
  2. Enabling forgery_check with forgery_action: "flag" and expecting the document step itself to reflect review — it won't; the step can approve while the session is force-held. Check session.metadata.background_review, not the module status, if you rely on this.
  3. Treating allow_bypass as "the applicant can force an approval" — it can't. Bypass only lets them proceed past a blocking screen; the terminal status (review/decline) doesn't change.

Risk-list screening

The extracted document number is screened against the tenant's risk lists (identifier type). A matching list entry can route the session to review, open a case, or block it depending on the list's configured action.

Minor detection

Turn on Minor Detection in the ID Verification node's settings and set a Minor Age (default 18). Once the document is read, the date of birth is taken from whichever field and calendar the document prints it in — Bikram Sambat on the front of a Nepali citizenship or voter card, Gregorian elsewhere (and on the citizenship back) — converted, and the applicant's exact age is computed at that moment. Below the Minor Age the applicant is a minor:

  • the result carries the warning minor_detected (not a review trigger by itself) and the verdict in data.minor;
  • the verification and the identity are tagged minor, and the console flags the applicant as a minor;
  • when the workflow has a Minor branch out of this node, the flow continues into it — typically a parent or guardian's ID and details. Adding a branch turns a simple workflow into an advanced (graph) one; see Concepts → Workflows. A branch can also follow the node's outcome (approved / in_review / declined) or be the otherwise path.

In the workflow builder, turn on Branch minors into a guardian flow under Minor Detection: the builder confirms the switch to an advanced workflow and adds a ready branch — a required Guardian ID Verification step behind a minor connection from this node, rejoining the main path (after Confirm Details when that follows the ID step, so a minor whose date of birth is only corrected there still reaches it). Adults never see the branch. Edit it like any other step afterwards — for example + Guardian details form adds a questionnaire for the guardian's relationship and contact details. On a workflow whose Minor branch runs before Confirm Details, a minor revealed there still gets the guardian steps: the hosted flow presents a branch step that opened behind the applicant next, and completion waits for it (409 steps_remaining).

A date of birth that can't be read or converted never flags a minor: the result says reason: "dob_unparseable" and carries a dob_unparseable warning, and the applicant continues on the main path.

"minor": {
  "is_minor": true,
  "minor_age": 18,
  "age": 16,
  "dob": "2010-03-02",
  "adult_on": "2028-03-02",
  "calendar": "BS",
  "dob_source": "date_of_birth",
  "dob_raw": "2066-11-18",
  "evaluated_at": "2026-09-24T08:01:10+00:00"
}

Steps on a branch that wasn't taken finish as skipped with data.branch: "not_taken" (or "unresolved" when their source never produced a verdict), so a guardian step never lingers as "not started" in the decision, the console or the webhook.

Guardian verification risk signals

An ID step on a Minor branch captures the parent or guardian's document, and it is checked against the minor's own identity. Four patterns are flagged as risk signals. The guardian's ID step keeps its own verdict (the applicant is never stopped at the step or told about a signal), but the signals decide the verification — see What the signals do.

Signal (warning code)Flagged whenRisk points
guardian_same_documentThe guardian's document is the minor's: the same document number (folded like Unique ID validation), or the very same front image.40
guardian_face_matches_minorThe guardian document's photo matches the minor's face — the minor's ID photo and, once captured, their live selfie — using the Face Match recognizer and threshold.40
guardian_too_youngThe guardian is under 18 (or under the workflow's Minor Age when that is higher), or less than 12 years older than the minor. Ages come from each document's date of birth, AD or Bikram Sambat.25
guardian_name_similarThe guardian's full name, or their first name, is at least 90% similar to the minor's (the bilingual name matcher). A shared surname alone is expected in a family and is never flagged.15

Each flagged signal adds its warning to the guardian's ID result, tags the verification guardian_risk and adds its risk points to the session score at finalisation (each signal counts once). The checks run when the guardian's ID is processed and again at completion (by then the minor's selfie exists). The evidence is in the guardian result's data.guardian_checks — returned by the session API and webhooks, never to the applicant's own flow:

"guardian_checks": {
  "minor_node_id": "ID_VERIFICATION",
  "signals": ["guardian_too_young"],
  "risk_points": 25,
  "thresholds": { "min_guardian_age": 18, "min_age_gap_years": 12,
                  "name_full_similarity": 90, "name_given_similarity": 90 },
  "checks": {
    "same_document": { "evaluated": true, "flagged": false, "number_match": false, "image_match": false },
    "guardian_age": { "evaluated": true, "flagged": true, "reasons": ["age_gap_too_small"],
                      "guardian_age": 25, "age_gap_years": 10 },
    "face": { "evaluated": true, "flagged": false,
              "comparisons": [{ "against": "minor_document", "evaluated": true, "matched": false, "similarity": 12.4 }] },
    "name": { "evaluated": true, "flagged": false, "full_name_similarity": 70.0,
              "given_name_similarity": 25.0, "shared_surname": true }
  },
  "evaluated_at": "2026-09-29T10:00:00+05:45"
}

A check that couldn't run says so (evaluated: false with a reason, e.g. guardian_dob_unreadable, face_engine_unavailable) and never flags. Minor detection and Unique ID validation are unaffected.

What the signals do

No risk rule is needed — the signals decide the verification at finalisation:

  • Any signal blocks automatic approval: a verification that would have been approved goes to review instead, and needs a maker and a checker (on a single-stage review chain, a second operator must confirm the decision).
  • guardian_same_document or guardian_face_matches_minor also routes the case to compliance review, whatever the risk score: the pipeline's compliance stage runs (a "Compliance review" stage is added when the chain has none) and the case is high priority.
  • A declined verification stays declined; one already in review keeps its status, and its case still gets the guardian routing.
  • A signal counts from the guardian's ID now, from an earlier attempt in the same verification, and from the same person's other verifications that nobody has cleared (a repeat submission).

The decision is recorded at risk.guardian on the session (API and webhooks: tier, action, signals, labels, held), on the timeline and in the audit log. The applicant only ever sees the generic in-review reason.

Unique ID validation

Turn on Unique ID Validation to require that the document number read here is not already held by another identity in your account. The number is folded (case, spaces and separators ignored; Devanagari digits read as ASCII) and compared with every stored identity document except the applicant's own (the same identity, or an identity with the same vendor_data / external user id) and the documents captured in this session. Non-identity paperwork (bills, statements, business documents) is never compared.

Match modeA match needs
Easy (default)The document number alone.
ExtensiveThe number and the date of birth and a fuzzy name match (bilingual names supported). A same-number record whose date of birth or name disagrees is listed under partial_matches for the reviewer but never triggers the action.

What a match does is set per node, like an AML match action:

ActionEffect on the ID step
In review (default)Held for manual review (a case opens).
RejectDeclined.
Allow but flagVerdict unchanged; the verification is tagged duplicate_id and 25 risk points are added at finalisation.

The result carries the warning duplicate_id_number for a full match (duplicate_id_partial when only partial matches exist) and the check itself in data.unique_id:

"unique_id": {
  "checked": true,
  "mode": "easy",
  "action": "review",
  "number_normalized": "PA1234567",
  "match_count": 1,
  "truncated": false,
  "matches": [{
    "individual_id": "9b1c…",
    "individual_name": "Aarav Sharma",
    "external_user_id": "cust_8841",
    "session_id": "5e0d…",
    "document_id": "c3a2…",
    "document_type": "passport",
    "country": "NP",
    "status": "approved",
    "captured_at": "2026-05-02T10:14:00+00:00",
    "signals": { "document_number": true }
  }],
  "partial_matches": [],
  "checked_at": "2026-09-24T08:01:10+00:00"
}

In Extensive mode each entry's signals also carries dob (true / false / null when it can't be compared), name (the fuzzy score, 0–100) and name_threshold (80). At most 25 records are listed (truncated: true beyond that); records_total and identities_total count every record holding the number and the distinct identities they belong to, and each entry carries the matching verification's console number as session_number. Without a readable number the check reports checked: false, reason: "no_document_number". The list names other people, so it is returned to you (API, webhooks, console) but never to the applicant's own flow. When the applicant corrects the number or date of birth on a Confirm Details step, both checks re-run on the confirmed values — a correction can add a finding but never erase one the document itself produced.

Result shape

ID_VERIFICATION returns the extracted fields, the detected_type and its confidence, an is_document_valid flag, and whether a portrait was found. Retrieve it from GET /v3/session/<id>/ (full result) or in summary form from decision_v3.id_verifications on the decision endpoint.

{
  "feature": "ID_VERIFICATION",
  "status": "approved",
  "score": 91.0,
  "warnings": [],
  "data": {
    "fields": {
      "full_name": "AARAV SHARMA",
      "document_number": "PA1234567",
      "date_of_birth": "1990-04-12",
      "expiry_date": "2032-01-10",
      "nationality": "NP",
      "issuing_state": "NP"
    },
    "translated_fields": {},
    "detected_type": "passport",
    "confidence": 0.97,
    "is_document_valid": true,
    "has_portrait": true
  }
}

Fetch it directly:

curl -s "$TF_BASE_URL/v3/session/$SESSION_ID/" \
  -H "x-api-key: $TF_API_KEY" \
  | jq '.results[] | select(.feature == "ID_VERIFICATION")'
const res = await fetch(`${process.env.TF_BASE_URL}/v3/session/${sessionId}/`, {
  headers: { "x-api-key": process.env.TF_API_KEY! },
});
const session = await res.json();
const idResult = session.results.find((r: any) => r.feature === "ID_VERIFICATION");
console.log(idResult.status, idResult.data.fields);
import requests

session = requests.get(
    f"{BASE_URL}/v3/session/{session_id}/",
    headers={"x-api-key": API_KEY},
    timeout=15,
).json()
id_result = next(r for r in session["results"] if r["feature"] == "ID_VERIFICATION")
print(id_result["status"], id_result["data"]["fields"])

Edge cases