Verification

Standalone tools

One-shot utilities — AML, face search, signature verify/search/enroll, document extract, 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> (or X-API-Key). See Authentication.
  • Images: base64 (optionally a data: URI), JPEG or PNG only, max 8 MB. Other formats (HEIC, WEBP, GIF, …) return 415 unsupported_image_type; a missing/undecodable image returns 400 <field>_required / 400 invalid_<field>.
  • Rate limit: the tools share a per-tenant bucket of 120 requests/minute (kyc_v3_tools). Over-limit requests get 429.
  • Entitlement + billing: each chargeable tool is gated by your plan. A disabled feature returns 403 feature_not_enabled (with a features array); an empty balance returns 402 insufficient_credits. A successful call debits one use.
  • Errors: /v3 tool errors return { "detail": "<code>" } with the HTTP status carrying the machine signal. Branch on status + the stable code, not the message.
EndpointPurposeKey body fieldsChargeable
POST /v3/tools/aml-check/Sanctions/PEP/adverse-media screeningfull_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 identitiesimage_base64, threshold?, top_k?
POST /v3/tools/signature-verify/1:1 signature comparisonreference_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 identityindividual_id | external_user_id, image_base64, label?
POST /v3/tools/document-extract/Identity-document OCRimage_base64, document_type?
POST /v3/tools/business-document/Business-document (KYB) classification + OCRimage_base64
POST /v3/tools/face-verify/1:1 face matchreference_base64, probe_base64
POST /v3/tools/ip-check/VPN/proxy/Tor/hosting risk + geolocation for one IPip
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 challengemethod (FLASHING | 3D_ACTION | GESTURE)
POST /v3/tools/liveness/verify/Score frames captured for that challengechallenge_id, frames / frames_base64, per-method metadata✅ on a verdict
POST /v3/tools/lens/{service}/Lens OCR for one enabled serviceimage_base64metered only when LENS_<SERVICE> has an explicit rate

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. An unregistered name returns 404 invalid_lens_service with the allowed list; a service that isn't on your plan returns 403 lens_service_not_enabled. 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.

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 bulk-imported through identity import — 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_base64stringbodyrequired
Probe face photo (JPEG/PNG, base64).
thresholdnumberbody
Match cutoff, clamped to 0.2–0.95. Default 0.35.
top_knumberbody
Max results, clamped to 1–50. Default 12.
{
  "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_base64stringbodyrequired
Reference signature (JPEG/PNG, base64).
probe_base64stringbodyrequired
Probe signature to compare.
thresholdnumberbody
Match cutoff, clamped to 0.2–0.95. Default 0.62.
{
  "score": 0.68,
  "verdict": "match",
  "matched": true,
  "threshold": 0.62
}

The verdict is derived against the requested threshold:

verdictCondition
matchscore ≥ threshold
reviewscore ≥ 0.75 × threshold
mismatchbelow the review floor

Note

If an input can't be read as a signature you get a 422no_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_base64stringbodyrequired
Probe signature (JPEG/PNG, base64).
thresholdnumberbody
Match cutoff, clamped to 0.2–0.95. Default 0.6.
top_knumberbody
Max results, clamped to 1–50. Default 12.
{
  "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_idstringbody
Identify the person by internal id (use this or external_user_id).
external_user_idstringbody
Identify the person by your own user id (most recent identity wins).
image_base64stringbodyrequired
Signature specimen (JPEG/PNG, base64).
labelstringbody
Optional free-text label (e.g. account-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.

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_base64stringbodyrequired
Document photo (JPEG/PNG, base64).
document_typestringbody

Optional type hint to recover documents auto-detect misses: passport, drivers_license, citizenship, pan, disability_id. Auto-detect covers most cases, and an unrecognised value falls back to it; disability_id runs a best-effort generic extraction (generic_extraction: true). A non-string value returns 400 invalid_document_type (not charged).

fieldsobject
Extracted fields (full name, document number, DOB, expiry, nationality).
translated_fieldsobject
Romanised / English read of the same fields for non-Latin documents.
detected_typestring
The classified document type.
confidencenumber
Classification confidence.
is_document_validboolean
Whether the read looks like a valid document.
has_portraitboolean
Whether a portrait was found.
{
  "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", are relabelled to aadhaar from OCR-text signals (display-level only). 503 ocr_not_configured if the OCR service isn't available; 422 ocr_no_fields if nothing readable was found.

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 }
FieldMeaning
methodPASSIVE, PASSIVE_VIDEO, FLASHING, 3D_ACTION or GESTURE.
statusapproved, declined or review (the engine couldn't decide either way).
passedtrue only when status is approved.
score0–100, the same scale as decision_v3.liveness_checks[].score.
reasonPresent 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_verifiedPassive 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_pass3D_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_base64stringbodyrequired
The selfie (JPEG/PNG, base64, ≤ 8 MB). Always required and always checked for a usable face (the 422s below), clip or not. It is what gets scored when no clip is sent or the video engine is unavailable.
video_base64stringbody
Optional ~2–3 s selfie clip, MP4/MOV or WebM (sniffed by container), ≤ 20 MB decoded. Switches the method to PASSIVE_VIDEO.
check_sunglassesbooleanbody
Treat sunglasses as a quality failure (the module's "Disallow sunglasses"). Default true.
{ "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 screen
ch = requests.post(f"{BASE_URL}/v3/tools/liveness/challenge/",
                   headers={"x-api-key": API_KEY}, json={"method": "GESTURE"},
                   timeout=15).json()
methodstringbodyrequired
FLASHING, 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, wait lead_ms (so the camera exposes the new light), grab one frame and record its timestamp, then hold until frame_ms has elapsed since the colour appeared. You end with exactly n frames. emitted_order is the list of colours you actually painted, in order (normally equal to sequence; any difference declines as sequence_altered). frame_timestamps are 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 as timing_anomaly.
  • 3D_ACTION — for each action: show its prompt (mirror the preview when mirror is true), give ~700 ms to start moving, then grab frames at ~10 fps for window_ms. Label every frame with the action it was captured under in frame_actions.
  • GESTURE — same as 3D_ACTION with prompts for hand gestures (~900 ms lead, ~9 fps for window_ms), labelled in frame_gestures. At least required gestures must be recognised, with the face in view while each is performed — hand-only frames decline as no_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_idstringbodyrequired
From the challenge call.
frames / frames_base64file[] / string[]bodyrequired
Multipart frames files, or JSON base64 strings, in capture order.
emitted_orderstring[]body
FLASHING (required): the colours displayed, in order.
frame_timestampsnumber[]body
FLASHING (optional): capture time of each frame in ms, strictly increasing.
frame_actionsstring[]body
3D_ACTION (required): the action each frame was captured for.
frame_gesturesstring[]body
GESTURE (required): the gesture each frame was captured for.
methodstringbody
Optional guard — 400 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.

StatusdetailWhen
400challenge_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_methodMissing or malformed input.
400challenge_kind_mismatchMetadata for a different method (e.g. frame_actions for a FLASHING challenge). Body adds challenge_method.
400frame_count_mismatch / frame_labels_mismatchFLASHING frames ≠ n (body adds expected_frames); labels ≠ frames.
404unknown_challengeNo 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.
409replayedAlready verified.
410expiredPast expires_at (for the first 24 hours after it; see 404).
413too_many_frames / frame_too_large / frames_too_largeOver the frame caps (body carries the cap).
415unsupported_image_typeA frame isn't JPEG/PNG.
503liveness_engine_unavailableNo real liveness engine loaded.

Error codes

Statusdetail codeMeaning
400<field>_required / invalid_<field>Missing or undecodable image / name.
400invalid_method / challenge_kind_mismatch / frame_count_mismatchLiveness: bad method, or frames that don't fit the challenge.
402insufficient_creditsTenant balance below the tool's rate.
403feature_not_enabledThe tool isn't on your plan (see features).
404individual_not_found / unknown_challengeSignature enroll target / liveness challenge doesn't exist.
409replayedLiveness challenge already verified.
410expiredLiveness challenge past expires_at.
413video_too_large / too_many_frames / frame_too_large / frames_too_largeLiveness clip or frames over the caps.
415unsupported_image_type / unsupported_video_typeNot JPEG/PNG (HEIC, WEBP, GIF, …); clip not MP4/WebM.
422no_face_detected / quality_rejected / no_signature_detected / not_a_signature / ocr_no_fieldsNothing usable in the input.
429Over the 120 req/min tools bucket.
503screening_unavailable / ocr_not_configured / liveness_engine_unavailableThe backing engine isn't reachable/configured.

FAQ