Verification

Face match

1:1 selfie-to-document biometric comparison, plus 1:N face search across your enrolled identities.

The FACE_MATCH module performs a 1:1 biometric comparison of the document portrait (from ID verification or the NFC chip) against the live selfie captured during liveness. It answers "is this the same person as the document?" — liveness answers "is this a live person?"; a real workflow runs both.

Info

Why both modules matter: liveness alone lets a live impostor through (they're a real person, just not the document holder); face match alone can be fooled by a printed photo unless liveness has already confirmed the input is a real, present human. Run them together — nearly every production workflow does.

How the decision is made

The engine compares face embeddings and returns a matched flag together with a calibrated similarity percentage:

  • The model's own cosine decision threshold is authoritative for matched. Genuine-match cosines sit well below 100% (AdaFace ~0.3–0.7), so the engine gates on the model's calibrated threshold rather than a naive similarity floor — a hard percentage floor would wrongly reject true matches.
  • matched true → approved. matched false → declined.
  • A capture problem (quality gate tripped, or no face found in the selfie) is retryable — the applicant is asked to retake. Only after exhausted retries does a capture problem escalate to review rather than a decline.

Note

The raw cosine similarity and the model's internal threshold are never surfaced. The stored result carries the caller-facing matched verdict and the calibrated similarity % — that is what every API response and the console read.

Review bands (min_similarity)

A tenant can set a min_similarity floor. It is a review band, not a decline: the biometric model decides same-person vs impostor; the floor only escalates a weak-but-genuine match to an operator instead of auto-approving it (similarity_below_minimum).

Precedence:

  1. A per-document-type rule — ID_VERIFICATION config document_rules[<type>].min_similarity — wins, taken from the document this node matched. Licence portraits and passport photo pages warrant different bands.
  2. Otherwise the workflow-wide min_similarity on the face-match step applies.

In a repeated-ID graph, the per-type band comes from the document wired to this face-match node, not the globally-latest upload — so a sibling document's band never leaks across.

How scoring and decisioning works

Capture problem check

If the selfie fails the quality gate, or no face is detected in it, the applicant is asked to retry — charged against the shared selfie-capture budget with LIVENESS (KYC_MAX_ATTEMPTS, default 3). Exhausted retries route to review, not decline — a genuine person with a stubbornly bad camera shouldn't be treated as an impostor.

Cosine match against the biometric model's threshold

matched = true when the embedding cosine similarity clears the model's own calibrated decision threshold (an internal detail — never a hard-coded round percentage like "80%"). matched → approved, not matched → declined.

Tenant review band, only when matched

If matched is true but the calibrated similarity still falls under the resolved min_similarity for this document type/workflow, the step is downgraded from approved to review with similarity_below_minimum — this only fires on genuine matches the model is less confident about, never on a clear mismatch (a mismatch is already declined).

Watchlist/blocklist face warnings layer on top

Independent of the match verdict, a face-level blocklist hit (e.g. a known-fraud face on file) can add its own warnings to the result without changing the underlying matched outcome — visible to an operator reviewing the case.

Common decline / review reasons

Reason codeOutcomeWhat triggers it
face_mismatchDeclinedCosine similarity falls below the model's calibrated match threshold — the selfie doesn't match the document portrait
similarity_below_minimumReviewA genuine match (matched: true) whose similarity is still below the tenant's min_similarity floor for this document type
no_face_detected (selfie)Retryable → review after exhausted attemptsNo usable face found in the selfie
Quality-gate rejectionRetryable → review after exhausted attemptsBlur, poor lighting, extreme angle, or multiple faces in the selfie
Blocklist face warningAdds a warning, doesn't change matchedThe selfie or document face matches a known-fraud entry in the tenant's face blocklist

Warning

Common integration pitfalls.

  1. Trying to set min_similarity as if it were a decline threshold — it isn't. A hard mismatch is already declined by the model before min_similarity is even consulted; the floor only ever moves a genuine match from approved to review.
  2. Forgetting that per-document-type min_similarity (via ID_VERIFICATION.document_rules) overrides the workflow-wide value — if you tune the workflow-level setting and don't see it apply, check whether a per-type rule is winning instead.
  3. Assuming the document portrait always comes from ID_VERIFICATION — when NFC is enabled, the chip portrait (DG2) can be the higher-quality source wired into face match instead. Check which node feeds which in a graph workflow before debugging a "wrong reference photo" issue.

Reference image sourcing

A workflow whose Face Match has no document step compares the selfie with a reference portrait. You can pass one when you create the session (reference.face_image — see Create a session); otherwise switch on Reference image sourcing in the Face Match node's config and the flow finds one itself, from three trusted sources in a fixed order:

TierSourceDetails
1External systemDynamic Form Actions marked as reference sources, run in your order. Each action's Response tab names the photo field (e.g. data.photo_base64, as Base64 or an image URL); an action that returns no photo — or an unusable one, or fails — falls through to the next.
2Existing Identity recordThe matching identity's selfie, else its document portrait, from a prior approved verification (matched by vendor_data / external user id). Imported photos and liveness stills are never used. Can be switched off.
3OperatorOnly when tiers 1–2 found nothing: an authorized operator — branch staff, a Video Call agent or a Maker — captures or uploads the reference from the console (the verification page, or the video-call console during a call), before the applicant's own capture.

The applicant can never supply their own reference — not configurable, at any tier; the hosted flow refuses any image it is sent (403 applicant_reference_not_allowed).

The lookups run as the applicant reaches the selfie step. When tiers 1–2 come up empty, the applicant waits on a "preparing your verification" screen while operators are alerted — a console notification, and an Awaiting reference badge (and filter) on the Verifications list. The wait ends when an operator supplies the photo, lets the applicant continue without one, or the node's operator timeout runs out (15 minutes by default). Without a reference, Face Match is held for review. In a workflow with a document step nothing waits: the document portrait is the compared face, and a sourced reference feeds the independent reference bands instead.

Every accepted photo — from any tier — must be a JPEG or PNG showing exactly one face; it is cropped with the same logic that isolates a document portrait, stored as the session's reference, and matched through the normal reference flow. The session then records where it came from:

"metadata": {
  "reference": {
    "authenticated": true,
    "vendor_identity": {
      "source": "external_system",
      "tier": 1,
      "action_id": "cbs_lookup",
      "action_name": "CBS customer photo lookup",
      "node_id": "FACE_MATCH",
      "at": "2026-09-24T08:01:10+00:00"
    }
  }
}

source is external_system (with action_id / action_name), identity_record (with matched_by and reference_session_id, the prior verification) or operator (with method — capture or upload — and the operator's id, email and role). The Face Match result carries the same object as data.reference_source, and an operator's capture or skip is on the verification's audit trail.

Reason codeOutcomeWhat triggers it
face_match_no_referenceReviewNothing to compare the selfie with — no reference at creation and none sourced
reference_operator_skippedReviewAn operator let the applicant continue without a reference
reference_operator_timeoutReviewNo operator supplied the reference before the timeout
reference_selfie_mismatchDeclinedThe selfie doesn't match the reference
reference_selfie_inconclusiveReviewNo readable face in the reference

1:N face search (duplicate detection)

Beyond the 1:1 module, the engine can rank your tenant's enrolled identities whose face matches a probe photo — the same embedding engine, used for duplicate-account detection. It runs automatically during a session (a duplicate face can flag the session for review) and is also exposed as the stateless /v3/tools/face-search/ tool:

curl -X POST "$TF_BASE_URL/v3/tools/face-search/" \
  -H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
  -d '{"image_base64":"<jpg/png base64>","threshold":0.35,"top_k":12}'
const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/face-search/`, {
  method: "POST",
  headers: { "x-api-key": process.env.TF_API_KEY!, "Content-Type": "application/json" },
  body: JSON.stringify({ image_base64: probeBase64, threshold: 0.35, top_k: 12 }),
});
const { matches } = await res.json();
import requests

resp = requests.post(
    f"{BASE_URL}/v3/tools/face-search/",
    headers={"x-api-key": API_KEY},
    json={"image_base64": probe_base64, "threshold": 0.35, "top_k": 12},
    timeout=15,
).json()
matches = resp["matches"]

threshold clamps to 0.2–0.95 (default 0.35), top_k ≤ 50 (default 12). It returns ranked matches with each identity's individual_id, external_user_id, status, score and thumbnail. Use it to run a new applicant's selfie against the existing population before approving them.

Two face sources are enrolled into the 1:N gallery: selfies captured live during a session, and photos you bulk-imported via identity import — being findable by 1:N is the whole point of an import. Document portraits, vendor reference faces and liveness stills are deliberately not in the gallery, so a hit always means "we hold this face as this person", never "this face appeared on some document."

Warning

The vendor_data trap. An identity is keyed on vendor_data → external_user_id, so a returning customer who verifies through a session created without vendor_data becomes a second identity — and the automatic duplicate check then flags them as a duplicate of their own imported record. Always create the session with vendor_data set to the same unique id you imported that person under; see importing existing identities.

Result shape

FACE_MATCH returns matched, the similarity score, source (DOCUMENT) and target (SELFIE), and any warnings (e.g. face_mismatch, similarity_below_minimum). Read it from GET /v3/session/<id>/ or decision_v3.face_matches on the decision endpoint.

{
  "feature": "FACE_MATCH",
  "status": "approved",
  "score": 78.4,
  "warnings": [],
  "data": {
    "matched": true,
    "similarity": 78.4,
    "source": "DOCUMENT",
    "target": "SELFIE"
  }
}
curl -s "$TF_BASE_URL/v3/session/$SESSION_ID/" \
  -H "x-api-key: $TF_API_KEY" \
  | jq '.results[] | select(.feature == "FACE_MATCH")'
const session = await fetch(`${process.env.TF_BASE_URL}/v3/session/${sessionId}/`, {
  headers: { "x-api-key": process.env.TF_API_KEY! },
}).then((r) => r.json());
const faceMatch = session.results.find((r: any) => r.feature === "FACE_MATCH");
console.log(faceMatch.data.matched, faceMatch.data.similarity);
import requests

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

Warning

The client-visible match result is advisory. Confirm the authoritative session decision via the signed webhook or GET /v3/session/<id>/decision/ before granting access.

FAQ