Verification
Standalone tools
One-shot utilities — AML, face search/enroll, signature verify/search/enroll, document extract, registry lookup, passive and active liveness — with full request/response JSON and no session attached.
The /v3/tools/* endpoints mirror the console's Manual Tools. They are stateless: none of them create or mutate a verification session. Each is tenant-scoped by your API key (x-api-key), and image arguments are base64 strings — raw or a data: URI.
Warning
A tool result is a lookup, not a verification. It carries no liveness, no face match, and no audit trail. Don't use document-extract or aml-check as a substitute for a real module in a compliance-bearing decision — use the tools for enrichment, triage and operator tooling.
Conventions
- Auth:
x-api-key: <tenant_api_key>(orX-API-Key). See Authentication. - Images: base64 (optionally a
data:URI), JPEG or PNG only, max 8 MB. Other formats (HEIC, WEBP, GIF, …) return415 unsupported_image_type; a missing/undecodable image returns400 <field>_required/400 invalid_<field>. - Rate limit: the tools share a per-tenant bucket of 120 requests/minute (
kyc_v3_tools). Over-limit requests get429. - Entitlement + billing: each chargeable tool is gated by your plan. A disabled feature returns
403 feature_not_enabled(with afeaturesarray); an empty balance returns402 insufficient_credits. A successful call debits one use at your plan's rate for the tool; failed calls are free. Send anIdempotency-Keyheader to make retries safe — a repeat of the same request (same key and body, same UTC day) isn't charged twice. - Errors:
/v3tool errors return{ "detail": "<code>" }with the HTTP status carrying the machine signal. Branch on status + the stable code, not the message.
| Endpoint | Purpose | Key body fields | Chargeable |
|---|---|---|---|
POST /v3/tools/aml-check/ | Sanctions/PEP/adverse-media screening | full_name | first_name+last_name, date_of_birth?, nationality?, country?, document_number?, lists?, match_threshold? | ✅ |
POST /v3/tools/face-search/ | 1:N face search over enrolled identities | image_base64, threshold?, top_k? | ✅ |
POST /v3/tools/signature-verify/ | 1:1 signature comparison | reference_base64, probe_base64, threshold? | ✅ |
POST /v3/tools/signature-search/ | 1:N signature search (beta) | image_base64, threshold?, top_k? | ✅ |
POST /v3/tools/signature-enroll/ | Add a signature specimen to an identity | individual_id | external_user_id, image_base64, label? | ✅ per specimen |
POST /v3/tools/face-enroll/ | Enroll a face into the 1:N gallery (needs the opt-in IDENTITY_IMPORT grant) | external_user_id | individual_id, image_base64, consent_reference, label? | ✅ per face |
POST /v3/tools/document-extract/ | Identity-document OCR | image_base64, document_type?, country? | ✅ |
POST /v3/tools/business-document/ | Business-document (KYB) classification + OCR | image_base64 | ✅ |
POST /v3/tools/registry-lookup/ | Company registrar / IRD PAN official-records lookup by number, with optional name match (needs the opt-in REGISTRY_VALIDATION grant) | kind (company | pan), number, name?, threshold? | ✅ when the registry answered |
POST /v3/tools/face-verify/ | 1:1 face match | reference_base64, probe_base64 | ✅ |
POST /v3/tools/ip-check/ | VPN/proxy/Tor/hosting risk + geolocation for one IP | ip | ✅ |
POST /v3/tools/image-forensics/ | Document tamper analysis (EXIF rules + tamper localisation), with an optional heatmap | image_base64, include_heatmap? (default true) | ✅ on a verdict |
POST /v3/tools/liveness/ | Passive liveness on a selfie (optionally + a short clip) | image_base64, video_base64?, check_sunglasses? | ✅ on a verdict |
POST /v3/tools/liveness/challenge/ | Mint a single-use active-liveness challenge | method (FLASHING | 3D_ACTION | GESTURE) | — |
POST /v3/tools/liveness/verify/ | Score frames captured for that challenge | challenge_id, frames / frames_base64, per-method metadata | ✅ on a verdict |
POST /v3/tools/lens/{service}/ | Lens OCR for one enabled service | image_base64 | ✅ under LENS_<SERVICE> |
Note
{service} on the Lens route is one of the Lens services enabled for your account — caf-front, caf-back, ont-front, ont-back, fdb-box, iptv, house-detection, speed-test, general, and the identity/insurance services. New services published in ThirdFactor's Lens service directory become available once they are enabled for your account. An unregistered name returns 404 invalid_lens_service with the allowed list; a service that isn't enabled for your account returns 403 with code: "service_disabled" (plus the historical detail: "lens_service_not_enabled") before anything is billed. The upstream Lens payload is returned verbatim, so the response shape is owned by the service.
AML check
Ad-hoc watchlist screening for a name — the same engine as the AML_SCREENING module. A name is the minimum useful selector; every other field narrows the candidate set.
curl -X POST "$TF_BASE_URL/v3/tools/aml-check/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{
"full_name": "Aarav Sharma",
"date_of_birth": "1990-04-12",
"nationality": "NP",
"lists": ["sanctions", "pep"],
"match_threshold": 0.7
}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/aml-check/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
full_name: "Aarav Sharma",
date_of_birth: "1990-04-12",
nationality: "NP",
lists: ["sanctions", "pep"],
match_threshold: 0.7,
}),
});
const screen = await res.json();
console.log(screen.risk_level, screen.matches.length);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/aml-check/",
headers={"x-api-key": API_KEY},
json={
"full_name": "Aarav Sharma",
"date_of_birth": "1990-04-12",
"nationality": "NP",
"lists": ["sanctions", "pep"],
"match_threshold": 0.7,
},
timeout=30,
)
screen = res.json()
print(screen["risk_level"], len(screen["matches"])){
"risk_level": "medium",
"matches": [],
"possible_matches": [
{
"entity_id": "NK-9x2",
"name": "Aarav Sharma",
"schema": "Person",
"score": 0.63,
"match": false,
"lists": ["pep"],
"topics": ["role.pep"],
"datasets": ["everypolitician"],
"birth_date": "",
"countries": ["np"],
"aliases": [],
"nationalities": ["np"],
"positions": ["Member of Parliament"],
"gender": "male",
"addresses": [],
"id_numbers": [],
"url": ""
}
],
"informational_matches": [],
"lists_checked": ["pep", "sanctions"],
"provider": "watchlist",
"dataset": "default",
"algorithm": "logic-v1",
"threshold": 0.7
}risk_level is high / medium / low / clear. A query with no name returns 400 name_required; an unreachable engine returns 503 screening_unavailable. Full field reference, list topics, scoring tiers and the corroboration rule: AML screening.
Face search
Ranks your tenant's enrolled identities whose face matches a probe photo — duplicate-account detection. Read-only (nothing is flagged); it mirrors the automatic duplicate check.
The gallery it searches holds selfies captured live during a session and photos enrolled from outside — bulk-imported through identity import or enrolled one at a time with face enroll — document portraits and liveness stills stay out of it. If you imported an existing customer base, create each returning customer's session with vendor_data set to the id they were imported under: verifying without it spawns a second identity that the duplicate check then flags against their own imported record. See importing existing identities.
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: imageB64, threshold: 0.35, top_k: 12 }),
});
const { matches } = await res.json();
console.log(matches.map((m: any) => [m.external_user_id, m.score]));import requests
res = requests.post(
f"{BASE_URL}/v3/tools/face-search/",
headers={"x-api-key": API_KEY},
json={"image_base64": image_b64, "threshold": 0.35, "top_k": 12},
timeout=30,
)
for m in res.json()["matches"]:
print(m["external_user_id"], m["score"])image_base64stringbodyrequiredthresholdnumberbodytop_knumberbody{
"matches": [
{
"individual_id": "9f3a…",
"name": "Aarav Sharma",
"external_user_id": "user-123",
"status": "active",
"kyc_status": "clear",
"score": 0.71,
"thumb": "https://…/face.jpg"
}
],
"threshold": 0.35
}422 no_face_detected if there's no usable face in the probe.
Signature verify
Compares a probe signature to a reference — structural similarity of stroke geometry (not pen dynamics). Both inputs are cropped to just the signature ink first, so a full document photo or a video frame is matched on its ink, not the surroundings.
curl -X POST "$TF_BASE_URL/v3/tools/signature-verify/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"reference_base64":"<base64>","probe_base64":"<base64>","threshold":0.62}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/signature-verify/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
reference_base64: referenceB64,
probe_base64: probeB64,
threshold: 0.62,
}),
});
const out = await res.json();
console.log(out.verdict, out.score);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/signature-verify/",
headers={"x-api-key": API_KEY},
json={"reference_base64": reference_b64, "probe_base64": probe_b64, "threshold": 0.62},
timeout=30,
)
out = res.json()
print(out["verdict"], out["score"])reference_base64stringbodyrequiredprobe_base64stringbodyrequiredthresholdnumberbody{
"score": 0.68,
"verdict": "match",
"matched": true,
"threshold": 0.62
}The verdict is derived against the requested threshold:
verdict | Condition |
|---|---|
match | score ≥ threshold |
review | score ≥ 0.75 × threshold |
mismatch | below the review floor |
Note
If an input can't be read as a signature you get a 422 — no_signature_detected when no stroke-shaped ink is found, or not_a_signature when a human face is detected (a photo of a person is not a signature). The tool never returns a falsely-confident match.
Signature search (beta)
Ranks your tenant's enrolled signature specimens against a probe — a recall aid for operators, not a verdict. The probe is cropped to the signature ink first, the same treatment enrollment applies, so both sides of the search are built from the same kind of crop.
curl -X POST "$TF_BASE_URL/v3/tools/signature-search/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"image_base64":"<png base64>","threshold":0.6,"top_k":12}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/signature-search/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ image_base64: imageB64, threshold: 0.6, top_k: 12 }),
});
const { matches, beta } = await res.json();
console.log(beta, matches.length);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/signature-search/",
headers={"x-api-key": API_KEY},
json={"image_base64": image_b64, "threshold": 0.6, "top_k": 12},
timeout=30,
)
data = res.json()
print(data["beta"], len(data["matches"]))image_base64stringbodyrequiredthresholdnumberbodytop_knumberbody{
"matches": [
{
"individual_id": "9f3a…",
"name": "Aarav Sharma",
"external_user_id": "user-123",
"status": "active",
"score": 0.64,
"thumb": "https://…/signature.png"
}
],
"threshold": 0.6,
"beta": true
}422 no_signature_detected if the probe has no readable signature. Confirm any hit with the 1:1 signature verify.
Signature enroll
Attaches a reference signature to an identity so it becomes searchable by signature search. The input is cropped to the signature ink first, so a full video frame / document photo enrolls a clean specimen.
curl -X POST "$TF_BASE_URL/v3/tools/signature-enroll/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"external_user_id":"user-123","image_base64":"<png base64>","label":"account-opening"}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/signature-enroll/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
external_user_id: "user-123",
image_base64: imageB64,
label: "account-opening",
}),
});
const out = await res.json(); // 201
console.log(out.sample_id, out.individual_id);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/signature-enroll/",
headers={"x-api-key": API_KEY},
json={
"external_user_id": "user-123",
"image_base64": image_b64,
"label": "account-opening",
},
timeout=30,
)
out = res.json() # 201
print(out["sample_id"], out["individual_id"])individual_idstringbodyexternal_user_id).external_user_idstringbodyimage_base64stringbodyrequiredlabelstringbodyaccount-opening), truncated to 120 chars.{
"sample_id": "c2b8…",
"individual_id": "9f3a…"
}On success returns 201. An unknown person returns 404 individual_not_found; an image with no readable signature returns 422 no_signature_detected.
Face enroll
Adds one face to your tenant's 1:N gallery so face search and the automatic duplicate check can find the person — the face counterpart of signature enroll, and the one-at-a-time sibling of identity import. Unlike signature enroll, an unknown external_user_id creates the identity. Needs the opt-in IDENTITY_IMPORT grant (platform-enabled per tenant) and is charged one FACE_ENROLL use per enrolled face.
curl -X POST "$TF_BASE_URL/v3/tools/face-enroll/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"external_user_id":"user-123","image_base64":"<jpeg base64>","label":"account-opening","consent_reference":"Customer T&C v3"}'- The photo must hold exactly one readable face (
422 no_face_detected/multiple_faces);consent_referenceis required. - Re-sending the same photo for the same person returns the existing face with
replayed: true(200, not charged again). - Matches on other identities come back in
duplicates(on_duplicate:reportdefault,skip→409,none). GET /v3/tools/face-enroll/?external_user_id=user-123lists the person's enrolled faces;DELETE /v3/tools/face-enroll/{face_id}/removes one. Neither is gated or charged.
Full reference: Standalone tools → Face enroll.
Document extract
Identity-document OCR — the same read as ID_VERIFICATION, run standalone. It uses the platform document-OCR service, so it works on any account regardless of the tenant's Lens config.
Gated by the DOCUMENT_OCR feature (403 feature_not_enabled when it is off, 402 insufficient_credits when the balance can't cover the rate) and charged one DOCUMENT_OCR use only when the read returned fields or generic text — 422 ocr_no_fields, 503 ocr_not_configured and other failed reads are not charged.
curl -X POST "$TF_BASE_URL/v3/tools/document-extract/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"image_base64":"<base64>","document_type":"passport"}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/document-extract/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ image_base64: imageB64, document_type: "passport" }),
});
const doc = await res.json();
console.log(doc.detected_type, doc.fields);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/document-extract/",
headers={"x-api-key": API_KEY},
json={"image_base64": image_b64, "document_type": "passport"},
timeout=30,
)
doc = res.json()
print(doc["detected_type"], doc["fields"])image_base64stringbodyrequireddocument_typestringbodyOptional hint from your account's document types — GET /v3/tools/document-extract/ lists them (free): the platform's country documents narrowed to the countries and document types enabled for your account, local types first, then the documents of the countries on the international classifier. Send a candidate key (NP:citizenship, IN:id_card, IN:aadhaar, IN:pan_card) or a bare type (passport, drivers_license, citizenship, pan_id, disability_id; legacy pan accepted — with country: IN it means India's PAN card). Auto-detect covers most cases and classifies within the same set — a document of a country on the international classifier is read by that classifier, with the same fields and country as when you send its hint; disability_id runs a best-effort generic extraction (generic_extraction: true). A hint outside the set returns 400 document_type_not_available; a non-string value 400 invalid_document_type (neither charged).
countrystringbodydocument_type, or limits auto-detect to that country's documents.fieldsobjecttranslated_fieldsobjectdetected_typestringconfidencenumberenginestringlocal (the platform document OCR) or international (the country's international classifier) — which engine read the fields.is_document_validbooleanhas_portraitbooleandocumentobjectkey, country, country_name, label, classifier) — e.g. an Aadhaar card read with India enabled comes back as IN:aadhaar, "Aadhaar Card".{
"fields": {
"full_name": "AARAV SHARMA",
"document_number": "PA1234567",
"date_of_birth": "1990-04-12",
"expiry_date": "2032-01-10",
"nationality": "NP"
},
"translated_fields": {},
"detected_type": "passport",
"confidence": 0.97,
"is_document_valid": true,
"has_portrait": true
}Template-less documents (e.g. disability cards) run a generic extraction — auto-detect fields plus best-effort local OCR text, flagged with generic_extraction: true and a full_text string. Aadhaar cards, which the classifiers can only call "national id" (or mistake for another card), and Indian PAN cards, which the local OCR reads as a PAN card without saying which country issued it, are recognised from OCR-text signals (the 4-4-4 Aadhaar number with its check digit, the UIDAI captions; the AAAAA9999A PAN number) and reported as aadhaar / pan_card with India as their country while India is enabled for your account. Auto-detected or sent as a hint, they are read the same way: by India's classifier first, and when it can't read them, by a best-effort read of the card's text (generic_extraction: true with its full_text). A document auto-detected from a country (or of a type) not enabled for your account is refused with 422 document_type_not_available rather than reported as something else — switching a country off removes its documents from the hint list and auto-detect immediately. 503 ocr_not_configured if the OCR service isn't available; 422 ocr_no_fields if nothing readable was found.
Registry lookup
KYB official-records check by number — the API twin of the console Registry Lookup tool, and the same registry validation a BUSINESS_DOCUMENT step runs on an uploaded company registration or VAT/PAN certificate. kind: "company" asks the Office of the Company Registrar by registration number; kind: "pan" asks the Inland Revenue Department (IRD) PAN search. Send the name you expect and it is fuzzy-matched against the registered English and Nepali names (legal suffixes, M/s / श्री and punctuation ignored).
Opt-in: gated on REGISTRY_VALIDATION (403 feature_not_enabled until the platform enables it for your account) and billed one REGISTRY_VALIDATION use per lookup the registry answered — matched, name_mismatch and not_found alike — at the same rate as the console tool and a workflow step's registry check. A lookup that couldn't be made is free, and so is a number that is only punctuation (.., /-/): there is nothing to look up, so it is refused as 400 invalid_number everywhere — and a workflow step that reads such a number off a certificate keeps the not_found verdict (held for review) without being charged.
The records are Nepal's, so the lookup follows the business-document tool's country rule: while Nepal is switched off for your account, registry-lookup and business-document both answer 422 document_type_not_available with the same body ({ detail, message, country: "NP" }) before anything runs — not charged.
curl -X POST "$TF_BASE_URL/v3/tools/registry-lookup/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"kind":"company","number":"000000/00/00","name":"Acme Technologies Pvt. Ltd."}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/registry-lookup/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ kind: "pan", number: "000000000", name: "Acme Technologies" }),
});
const lookup = await res.json();
console.log(lookup.status, lookup.record?.name_english);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/registry-lookup/",
headers={"x-api-key": API_KEY},
json={"kind": "pan", "number": "000000000", "name": "Acme Technologies"},
timeout=60,
)
lookup = res.json()
print(lookup["status"], lookup["record"] and lookup["record"]["name_english"])kindstringbodyrequiredcompany (company registration number) or pan (9-digit PAN).numberstringbodyrequirednamestringbodythresholdnumberbodyDefault: 0.8statusstringmatched · name_mismatch (record found, name below threshold) · not_found.foundbooleanname_checkobject | null{ similarity, threshold, matched } (0–1) when a name was sent and a record found.recordobject | nullname_english / name_nepali, number, registration_date_bs, address, plus company_type (company) or office, registrations, tax_clearance (PAN); null when not found.{
"kind": "company",
"source": "company_registrar",
"status": "matched",
"found": true,
"number": "000000/00/00",
"number_used": "000000/00/00",
"name_check": { "similarity": 1.0, "threshold": 0.8, "matched": true },
"record": {
"number": "000000",
"name_english": "Acme Technologies",
"name_nepali": "एक्मे टेक्नोलोजिज",
"registration_date_bs": "2081-08-27",
"address": "Lalitpur Metropolitan City-1, Lalitpur",
"company_type": "All Nepali Ownership",
"office": null,
"registrations": null,
"tax_clearance": null
}
}400 invalid_number when the number is only punctuation or outside the registry's format (with the reason in message), 422 document_type_not_available while Nepal is switched off for your account, 502 registry_unavailable when the registry or the government website behind it didn't answer, 503 registry_not_configured when the deployment has no registry configured — none of them charged. Full reference: Standalone tools → Registry lookup.
Liveness
Three endpoints, one engine: the same models, verdict rules and pass floors as the Liveness module with its default configuration. Unlike a session they keep nothing — the selfie, clip and frames are scored in memory and discarded; the only stored row is an active challenge (its colour/action sequence and expiry), which anti-replay needs and which is deleted a day after it expires.
If you'd rather not build a capture UI at all, run liveness as a one-module hosted session instead — see Liveness via API for when to pick which.
Gate and billing. Each call needs the LIVENESS module and the method key (PASSIVE, PASSIVE_VIDEO, FLASHING, 3D_ACTION, GESTURE) on your plan; a 403 feature_not_enabled lists every key that's off. This is stricter than a session, which never drops liveness: a session whose method is switched off quietly runs PASSIVE instead, even when PASSIVE itself is off. The tools never substitute a method — a switched-off key, PASSIVE included, is a 403. A call is billed exactly like one liveness run in a session: under the method key when that method has its own rate, otherwise under LIVENESS. Only a returned verdict (approved, declined or review) is billed — capture failures (422), an unavailable engine (503), input errors and challenge issuing are free.
Response. Every verdict has the same shape:
{ "method": "PASSIVE", "status": "approved", "passed": true, "score": 91.37 }| Field | Meaning |
|---|---|
method | PASSIVE, PASSIVE_VIDEO, FLASHING, 3D_ACTION or GESTURE. |
status | approved, declined or review (the engine couldn't decide either way). |
passed | true only when status is approved. |
score | 0–100, the same scale as decision_v3.liveness_checks[].score. |
reason | Present when not approved — e.g. liveness_below_threshold, video_liveness_failed, still_clip_mismatch, spoof_detected, sequence_altered, timing_anomaly, weak_color_response, screen_replay_suspected, flash_liveness_low, action_not_completed, insufficient_motion, planar_motion_detected, action_liveness_failed, gesture_not_performed, no_face_detected, antispoof_unavailable, gesture_liveness_failed. |
video_verified | Passive with a clip only: false when the hosted video engine didn't answer, or the clip couldn't be decoded to check it against the still, and the still was scored instead. |
per_action_pass / per_gesture_pass | 3D_ACTION / GESTURE only: {item: bool} for each challenged item. |
Model names, raw anti-spoof probabilities and thresholds are never returned.
Passive liveness
curl -X POST "$TF_BASE_URL/v3/tools/liveness/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"image_base64":"<jpg/png base64>","check_sunglasses":true}'const res = await fetch(`${process.env.TF_BASE_URL}/v3/tools/liveness/`, {
method: "POST",
headers: { "x-api-key": process.env.TF_API_KEY!, "Content-Type": "application/json" },
// Add video_base64 (MP4 or WebM, ≤ 20 MB) to run PASSIVE_VIDEO.
body: JSON.stringify({ image_base64: selfieB64 }),
});
const out = await res.json();
if (res.status === 422) console.log("retake:", out.reason ?? out.detail);
else console.log(out.status, out.score);import requests
res = requests.post(
f"{BASE_URL}/v3/tools/liveness/",
headers={"x-api-key": API_KEY},
json={"image_base64": selfie_b64, "video_base64": clip_b64}, # clip optional
timeout=60,
)
print(res.status_code, res.json())image_base64stringbodyrequired422s below), clip or not. It is what gets scored when no clip is sent or the video engine is unavailable.video_base64stringbodyPASSIVE_VIDEO.check_sunglassesbooleanbodytrue.{ "method": "PASSIVE_VIDEO", "status": "declined", "passed": false, "score": 18.2,
"reason": "video_liveness_failed", "video_verified": true }With a clip, the verdict is the clip's — but only for the person in the still. A few frames from the clip are face-matched against image_base64; when the clip shows someone else (or no readable face at all) the result is declined with still_clip_mismatch, so an approved PASSIVE_VIDEO still is safe to hand to POST /v3/tools/face-verify/.
Errors: 422 quality_rejected with a reason you can show as a retake hint (too_blurry, face_too_small, poor_lighting, sunglasses_detected, face_occluded, face_not_frontal, low_quality, multiple_faces); 422 no_face_detected; 413 video_too_large; 415 unsupported_video_type; 503 liveness_engine_unavailable. None are billed.
Active liveness: challenge
Mint a challenge, run it in your own capture UI, then verify. A challenge is single-use, expires after 2 minutes (expires_at) and belongs to your tenant only.
curl -X POST "$TF_BASE_URL/v3/tools/liveness/challenge/" \
-H "x-api-key: $TF_API_KEY" -H "Content-Type: application/json" \
-d '{"method":"FLASHING"}'const ch = await fetch(`${process.env.TF_BASE_URL}/v3/tools/liveness/challenge/`, {
method: "POST",
headers: { "x-api-key": process.env.TF_API_KEY!, "Content-Type": "application/json" },
body: JSON.stringify({ method: "3D_ACTION" }),
}).then((r) => r.json());
// ch.actions, ch.prompts, ch.window_ms → drive your capture screench = requests.post(f"{BASE_URL}/v3/tools/liveness/challenge/",
headers={"x-api-key": API_KEY}, json={"method": "GESTURE"},
timeout=15).json()methodstringbodyrequiredFLASHING, 3D_ACTION or GESTURE. Anything else is 400 invalid_method.The 201 body carries the same fields the hosted flow's capture screens use:
{ "challenge_id": "0b6a2c1e-…", "method": "FLASHING",
"sequence": ["R", "W", "G", "B", "R", "G", "B"],
"frame_ms": 450, "lead_ms": 250, "n": 7, "expires_at": "2026-09-13T08:02:00Z" }{ "challenge_id": "…", "method": "3D_ACTION", "actions": ["turn_left", "look_up", "smile"],
"prompts": {"turn_left": "Turn your head to the left", "look_up": "Tilt your head up", "smile": "Smile"},
"window_ms": 4000, "mirror": true, "expires_at": "…" }{ "challenge_id": "…", "method": "GESTURE", "gestures": ["palm", "like", "peace"],
"prompts": {"palm": "Show your open palm", "like": "Give a thumbs up", "peace": "Show a peace sign (two fingers)"},
"required": 2, "window_ms": 4000, "expires_at": "…" }Capture contract (what the hosted UI does — the scorers are tuned to it):
- FLASHING — front camera, face filling the frame, screen brightness up. Fill the screen black for ~500 ms, then for each colour in
sequence(R#ff0000,G#00ff00,B#0000ff,W#ffffff): paint the whole screen that colour, waitlead_ms(so the camera exposes the new light), grab one frame and record its timestamp, then hold untilframe_mshas elapsed since the colour appeared. You end with exactlynframes.emitted_orderis the list of colours you actually painted, in order (normally equal tosequence; any difference declines assequence_altered).frame_timestampsare the capture times in milliseconds from any monotonic clock (e.g.performance.now()), one per frame and strictly increasing — out-of-order or mismatched timestamps decline astiming_anomaly. - 3D_ACTION — for each action: show its prompt (mirror the preview when
mirroris true), give ~700 ms to start moving, then grab frames at ~10 fps forwindow_ms. Label every frame with the action it was captured under inframe_actions. - GESTURE — same as 3D_ACTION with
promptsfor hand gestures (~900 ms lead, ~9 fps forwindow_ms), labelled inframe_gestures. At leastrequiredgestures must be recognised, with the face in view while each is performed — hand-only frames decline asno_face_detected.
Active liveness: verify
Accepts multipart/form-data — repeated frames files, metadata arrays as JSON strings, exactly what the hosted flow sends — or JSON with frames_base64. Frames are JPEG/PNG, at most 180 frames, 2 MB each and 32 MB total.
# FLASHING, multipart
curl -X POST "$TF_BASE_URL/v3/tools/liveness/verify/" \
-H "x-api-key: $TF_API_KEY" \
-F challenge_id=0b6a2c1e-… \
-F [email protected] -F [email protected] -F [email protected] -F [email protected] \
-F [email protected] -F [email protected] -F [email protected] \
-F 'emitted_order=["R","W","G","B","R","G","B"]' \
-F 'frame_timestamps=[812.4,1263.0,1714.8,2166.1,2617.5,3068.9,3520.2]'// 3D_ACTION, multipart (browser or Node 18+)
const fd = new FormData();
frames.forEach((blob, i) => fd.append("frames", blob, `f${i}.jpg`));
fd.append("challenge_id", ch.challenge_id);
fd.append("frame_actions", JSON.stringify(frameActions)); // one label per frame
const out = await fetch(`${process.env.TF_BASE_URL}/v3/tools/liveness/verify/`, {
method: "POST",
headers: { "x-api-key": process.env.TF_API_KEY! },
body: fd,
}).then((r) => r.json());
console.log(out.status, out.per_action_pass);# GESTURE, JSON
res = requests.post(
f"{BASE_URL}/v3/tools/liveness/verify/",
headers={"x-api-key": API_KEY},
json={
"challenge_id": ch["challenge_id"],
"frames_base64": frames_b64, # capture order
"frame_gestures": frame_labels, # same length as frames_base64
},
timeout=120,
)
print(res.status_code, res.json())challenge_idstringbodyrequiredframes / frames_base64file[] / string[]bodyrequiredframes files, or JSON base64 strings, in capture order.emitted_orderstring[]bodyframe_timestampsnumber[]bodyframe_actionsstring[]bodyframe_gesturesstring[]bodymethodstringbody400 challenge_kind_mismatch if it isn't the challenge's method.{ "method": "GESTURE", "status": "declined", "passed": false, "score": 33.3,
"reason": "gesture_not_performed",
"per_gesture_pass": { "palm": true, "like": false, "peace": false } }Single use. Request validation runs before the challenge is spent, so a 400/413/415 lets you fix the request and retry with the same challenge_id. Once it gets past validation the challenge is claimed atomically: a second verify — even a concurrent one — gets 409 replayed and is neither scored nor billed. A verdict or a 503 spends the challenge; mint a new one to retry.
| Status | detail | When |
|---|---|---|
| 400 | challenge_id_required, frames_required, invalid_frames_base64, emitted_order_required, invalid_emitted_order, invalid_frame_timestamps, frame_actions_required, frame_gestures_required, invalid_frame_actions, invalid_frame_gestures, invalid_method | Missing or malformed input. |
| 400 | challenge_kind_mismatch | Metadata for a different method (e.g. frame_actions for a FLASHING challenge). Body adds challenge_method. |
| 400 | frame_count_mismatch / frame_labels_mismatch | FLASHING frames ≠ n (body adds expected_frames); labels ≠ frames. |
| 404 | unknown_challenge | No such challenge for your tenant (another tenant's id reads the same). A challenge more than 24 hours past expires_at has been deleted and also reads as unknown — mint a new one. |
| 409 | replayed | Already verified. |
| 410 | expired | Past expires_at (for the first 24 hours after it; see 404). |
| 413 | too_many_frames / frame_too_large / frames_too_large | Over the frame caps (body carries the cap). |
| 415 | unsupported_image_type | A frame isn't JPEG/PNG. |
| 503 | liveness_engine_unavailable | No real liveness engine loaded. |
Error codes
| Status | detail code | Meaning |
|---|---|---|
| 400 | <field>_required / invalid_<field> | Missing or undecodable image / name. |
| 400 | invalid_method / challenge_kind_mismatch / frame_count_mismatch | Liveness: bad method, or frames that don't fit the challenge. |
| 400 | kind_required / invalid_kind / number_required / invalid_number / invalid_name / invalid_threshold | Registry lookup: bad input, or a number outside the registry's format (not charged). |
| 402 | insufficient_credits | Tenant balance below the tool's rate. |
| 403 | feature_not_enabled | The tool isn't on your plan (see features). |
| 404 | individual_not_found / unknown_challenge | Signature enroll target / liveness challenge doesn't exist. |
| 409 | replayed | Liveness challenge already verified. |
| 410 | expired | Liveness challenge past expires_at. |
| 413 | video_too_large / too_many_frames / frame_too_large / frames_too_large | Liveness clip or frames over the caps. |
| 415 | unsupported_image_type / unsupported_video_type | Not JPEG/PNG (HEIC, WEBP, GIF, …); clip not MP4/WebM. |
| 422 | no_face_detected / quality_rejected / no_signature_detected / not_a_signature / ocr_no_fields | Nothing usable in the input. |
| 422 | document_type_not_available | Business document / registry lookup: Nepal is switched off for your account ({ detail, message, country: "NP" }, not charged). |
| 429 | — | Over the 120 req/min tools bucket. |
| 502 | registry_unavailable | Registry lookup: the registry or the official website behind it didn't answer (not charged). |
| 503 | screening_unavailable / ocr_not_configured / liveness_engine_unavailable / face_engine_unavailable / registry_not_configured | The backing engine isn't reachable/configured. |