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.

1:N face search (duplicate detection)

Beyond the 1:1 module, the engine can rank your tenant's enrolled identities whose selfie 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.

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