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. matchedtrue →approved.matchedfalse →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:
- A per-document-type rule —
ID_VERIFICATIONconfigdocument_rules[<type>].min_similarity— wins, taken from the document this node matched. Licence portraits and passport photo pages warrant different bands. - Otherwise the workflow-wide
min_similarityon 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 code | Outcome | What triggers it |
|---|---|---|
face_mismatch | Declined | Cosine similarity falls below the model's calibrated match threshold — the selfie doesn't match the document portrait |
similarity_below_minimum | Review | A 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 attempts | No usable face found in the selfie |
| Quality-gate rejection | Retryable → review after exhausted attempts | Blur, poor lighting, extreme angle, or multiple faces in the selfie |
| Blocklist face warning | Adds a warning, doesn't change matched | The selfie or document face matches a known-fraud entry in the tenant's face blocklist |
Warning
Common integration pitfalls.
- Trying to set
min_similarityas if it were a decline threshold — it isn't. A hard mismatch is alreadydeclinedby the model beforemin_similarityis even consulted; the floor only ever moves a genuine match from approved to review. - Forgetting that per-document-type
min_similarity(viaID_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. - 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.