Reference
Sessions
Full /v3/session/ lifecycle — create, read, decision, status, tags, logs, checklist, PDF.
ThirdFactor Verify is driven through /v3/session/. A session runs a KYC
workflow's enabled modules in the hosted flow and produces a decision. All
endpoints on this page are on the /v3 surface and authenticate with
x-api-key.
Info
Base URL is https://<your-domain>/v3 — there is no /api/v1 prefix on the
ThirdFactor Verify surface.
When to use sessions directly
Create a session with POST /v3/session/ when you want a standalone KYC
check — no multi-step form, no branching flow, just "verify this person's
identity and tell me the outcome." If you already have (or want) a
console-designed application flow with form fields and a review queue, use
Applications instead; a flow's sdk step
spawns a session under the hood and both surfaces stay readable.
Sessions (/v3) | Applications (/api/v1) | |
|---|---|---|
| Best for | Standalone identity verification | Multi-step onboarding flows |
| Auth on create | Raw API key | Partner-signed JWT (API key signs it) |
| Decision contract | decision + decision_v3 breakdown | Stable decision (5 values) |
| Custom form fields | ❌ — KYC modules only | ✅ — flow-defined fields |
| Risk list matching | ❌ | ✅ — flow blocklist steps |
| Per-module results | ✅ — full snapshot | Via the spawned child session |
Session lifecycle
not_started ──▶ in_progress ──▶ in_review ──▶ approved
│ │
├──▶ declined │
├──▶ abandoned │
├──▶ expired / kyc_expired │
└──▶ awaiting_user / resubmitted (loops back)status is the full lifecycle state machine; decision is the compact signal
you branch on. Possible status values: not_started, in_progress,
in_review, approved, declined, abandoned, expired, resubmitted,
awaiting_user, kyc_expired.
Create a session
POST /v3/session/
x-api-key: <tenant_api_key>
Content-Type: application/jsonworkflow_idstringbodyKYC workflow UUID (from GET /v3/workflows/). The tenant default workflow is
used when omitted.
session_kindstringbodyDefault: userCurrently only user is accepted.
vendor_datastringbodyYour external user reference. Doubles as the linked identity's
external_user_id.
callback_urlstringbodyWhere the applicant is redirected after completion.
contact_emailstringbodyApplicant email; used for OTP and notifications.
contact_phonestringbodyApplicant phone; used for OTP and notifications.
expires_in_hoursintegerbodyDefault: 48Hosted-URL lifetime, 1..720.
localestringbodyDefault: enUI language for the hosted flow.
metadataobjectbodyFree-form JSON you want echoed back on session reads. Not shown to the applicant.
prefillobjectbodyApplicant identity to pre-populate the flow. Only these keys are used; unknown
keys are ignored: full_name, first_name, last_name, display_name,
date_of_birth (YYYY-MM-DD), email, phone, nationality (ISO-2),
address_line1, address_line2, city, state, country (ISO-2),
zip_code.
referenceobjectbodyOptional reference data you already hold, matched against what the applicant
captures and used to mark the identity as vendor-authenticated. Keys (all
optional): face_image / document_image / signature_image (base64,
data: URI ok), authenticated (boolean — the user is already
authenticated in your app), vendor_identity (free dict describing your
auth, e.g. { "method": "password", "id": "..." }).
In a workflow with Face Match but no ID Verification, face_image is
what the selfie is matched against — send it with every such session, or
Face Match is held for review (face_match_no_reference), unless the Face
Match node sources its reference in-flow (an external system, an earlier
approved verification, or an operator — see
Face match → Reference image sourcing).
With ID Verification on, the selfie is matched against the document portrait
and face_image is an extra, independent check.
curl -X POST https://acme.thirdfactor.ai/v3/session/ \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: user-123-signup" \
-d '{
"workflow_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"vendor_data": "your-user-reference-123",
"callback_url": "https://partner.example.com/kyc/done",
"contact_email": "[email protected]",
"contact_phone": "9841000000",
"expires_in_hours": 24,
"locale": "en",
"prefill": {
"full_name": "Aarav Sharma",
"date_of_birth": "1990-04-12",
"nationality": "NP"
}
}'const resp = await fetch("https://acme.thirdfactor.ai/v3/session/", {
method: "POST",
headers: {
"x-api-key": process.env.OBSIDIAN_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "user-123-signup",
},
body: JSON.stringify({
workflow_id: "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
vendor_data: "your-user-reference-123",
callback_url: "https://partner.example.com/kyc/done",
contact_email: "[email protected]",
contact_phone: "9841000000",
expires_in_hours: 24,
locale: "en",
prefill: { full_name: "Aarav Sharma", date_of_birth: "1990-04-12", nationality: "NP" },
}),
});
const session = await resp.json();
const redirectTo = session.url;import requests
resp = requests.post(
"https://acme.thirdfactor.ai/v3/session/",
headers={
"x-api-key": OBSIDIAN_API_KEY,
"Idempotency-Key": "user-123-signup",
},
json={
"workflow_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"vendor_data": "your-user-reference-123",
"callback_url": "https://partner.example.com/kyc/done",
"contact_email": "[email protected]",
"contact_phone": "9841000000",
"expires_in_hours": 24,
"locale": "en",
"prefill": {
"full_name": "Aarav Sharma",
"date_of_birth": "1990-04-12",
"nationality": "NP",
},
},
)
session = resp.json()
redirect_to = session["url"]Response Example
{
"id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"session_kind": "user",
"status": "not_started",
"url": "https://acme.thirdfactor.ai/verify/<session_token>",
"vendor_data": "your-user-reference-123",
"decision": {},
"expires_at": "2026-07-04T08:00:00Z",
"created_at": "2026-07-03T08:00:00Z"
}idstringsession_kindstringuser.statusstringnot_started.urlstringAbsolute hosted-flow URL on the tenant's domain. Redirect the end user here.
vendor_datastringdecisionstringin_progress.expires_atstringcreated_atstringRedirect the end user to url. The hosted flow runs the workflow's enabled
modules and the server enforces the configured liveness method — it cannot be
downgraded client-side.
Note
Creating a session debits the tenant's SESSION_CREATE credit rate. When the
balance is exhausted the call returns 402 insufficient_credits. See
Core concepts → Credits.
Idempotent creates
Send an Idempotency-Key header to make retries safe. A repeat create with the
same key returns the original session (201) and is never charged twice.
Idempotency-Key: <your-unique-key>Errors
| Status | Code | When |
|---|---|---|
400 | validation error (field-specific message) | expires_in_hours outside 1..720, malformed contact_email, etc. |
401 | invalid API key | Missing or wrong x-api-key. |
402 | insufficient_credits | Tenant balance can't cover the SESSION_CREATE fee. |
404 | workflow_id not found. | workflow_id doesn't exist for this tenant. |
429 | rate-limited | Over the kyc_v3_session_create bucket. |
500 | internal | The credit debit failed for a reason other than an insufficient balance. |
List sessions
GET /v3/session/ # bare array, newest first (alias: GET /v3/sessions)statusstringqueryFilter by lifecycle status. An unknown value returns 400 invalid_status.
session_kindstringqueryFilter by session kind. An unknown value returns 400 invalid_session_kind.
pageintegerqueryDefault: 1Page number.
page_sizeintegerqueryDefault: 50Items per page, clamped to a max of 200.
The response body stays a bare JSON array. Totals and paging ride in response headers:
| Header | Meaning |
|---|---|
X-Total-Count | Total matching sessions. |
X-Page | Current page. |
X-Page-Size | Page size. |
curl -sD - "https://acme.thirdfactor.ai/v3/session/?status=approved&page=1&page_size=50" \
-H "x-api-key: $OBSIDIAN_API_KEY"const resp = await fetch(
"https://acme.thirdfactor.ai/v3/session/?status=approved&page=1&page_size=50",
{ headers: { "x-api-key": process.env.OBSIDIAN_API_KEY } },
);
const total = resp.headers.get("X-Total-Count");
const sessions = await resp.json();resp = requests.get(
"https://acme.thirdfactor.ai/v3/session/",
headers={"x-api-key": OBSIDIAN_API_KEY},
params={"status": "approved", "page": 1, "page_size": 50},
)
total = resp.headers["X-Total-Count"]
sessions = resp.json()Response Example
[
{
"id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"session_kind": "user",
"status": "approved",
"stage": "Approved",
"url": "https://acme.thirdfactor.ai/verify/<session_token>",
"vendor_data": "your-user-reference-123",
"workflow": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"decision": { "ID_VERIFICATION": { "feature": "ID_VERIFICATION", "status": "approved", "score": 92, "warnings": [], "data": { "…": "…" } } },
"decision_v3": { "id_verifications": [], "liveness_checks": [], "face_matches": [], "aml_screenings": [], "reviews": [] },
"risk": null,
"tags": [],
"credits_charged": 3.0,
"callback_url": "https://partner.example.com/kyc/done",
"metadata": {},
"expires_at": "2026-07-04T08:00:00Z",
"created_at": "2026-07-03T08:00:00Z",
"completed_at": "2026-07-03T08:12:00Z"
}
]Errors
| Status | Code | When |
|---|---|---|
400 | invalid_status | status query param isn't a known lifecycle status. |
400 | invalid_session_kind | session_kind query param isn't a known kind. |
401 | invalid API key | Missing or wrong x-api-key. |
429 | rate-limited | Over the kyc_v3_read bucket. |
Read a session
GET /v3/session/<session_id>/ # full session + module resultsReturns the full session: the decision, every workflow node with its module
result (modules), a flat inventory of every evidence file (media), the
extracted identity, the risk profile, the network / device trail and
1:N duplicates. This is exactly the session object every
identity.kyc.session.* webhook carries. Use this server-side call — never a
browser redirect — as the authoritative record of a verification.
curl -s "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b/" \
-H "x-api-key: $OBSIDIAN_API_KEY"const resp = await fetch(
`https://acme.thirdfactor.ai/v3/session/${sessionId}/`,
{ headers: { "x-api-key": process.env.OBSIDIAN_API_KEY } },
);
const session = await resp.json();resp = requests.get(
f"https://acme.thirdfactor.ai/v3/session/{session_id}/",
headers={"x-api-key": OBSIDIAN_API_KEY},
)
session = resp.json()Response Example
{
"id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"session_kind": "user",
"status": "in_review",
"stage": "Case Created",
"url": "https://acme.thirdfactor.ai/verify/<session_token>",
"vendor_data": "your-user-reference-123",
"workflow": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"decision": {
"ID_VERIFICATION": { "feature": "ID_VERIFICATION", "status": "approved", "score": 92, "warnings": [], "data": { "document_id": "3c1d9a4e-…", "front_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/passport.jpg" } }
},
"decision_v3": {
"id_verifications": [{
"node_id": "ID_VERIFICATION", "status": "approved", "score": 92, "warnings": [],
"data": {
"document_id": "3c1d9a4e-…", "document_type": "passport",
"front_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/passport.jpg",
"back_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/back/passport.jpg",
"portrait_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/portrait/passport.jpg"
}
}],
"liveness_checks": [{
"node_id": "LIVENESS", "status": "approved", "score": 95, "warnings": [],
"data": {
"method": "FLASHING",
"selfie_url": "https://acme.thirdfactor.ai/v3/media/kyc/faces/selfie.jpg",
"event_stills": [
{ "label": "Red flash", "url": "https://acme.thirdfactor.ai/v3/media/kyc/faces/liveness-red-flash.jpg" }
]
}
}, {
"node_id": "LIVENESS_2", "status": "approved", "score": 96, "warnings": [],
"data": {
"method": "PASSIVE_VIDEO",
"selfie_url": "https://acme.thirdfactor.ai/v3/media/kyc/faces/selfie-2.jpg",
"video_url": null,
"video_status": "pending"
}
}],
"face_matches": [{
"node_id": "FACE_MATCH", "status": "approved", "score": 94, "warnings": [],
"data": {
"matched": true, "similarity": 94,
"source_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/portrait/passport.jpg",
"target_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/faces/selfie.jpg"
}
}],
"questionnaire_responses": [{
"node_id": "QUESTIONNAIRE", "status": "approved", "score": 100, "warnings": [],
"data": {
"answers": { "evidence": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/scan.jpg" },
"questions": [{
"id": "evidence", "label": "Supporting document", "type": "file",
"answer": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/scan.jpg",
"answer_file_id": "9b2e5f10-…",
"answer_file_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/scan.jpg"
}]
}
}],
"aml_screenings": [{ "node_id": "AML_SCREENING", "status": "approved", "score": null, "warnings": [], "data": { "matches": [] } }],
"video_calls": [{
"id": "a41f0c2e-…", "status": "completed", "verdict": "pass",
"verdict_reason": "Documents matched the applicant",
"duration_s": 184, "completed_at": "2026-07-03T08:11:40Z",
"recording_url": "https://acme.thirdfactor.ai/v3/media/kyc/recordings/webm/kyc-7f2c.webm",
"recording_status": "ready",
"screenshots": [
{ "label": "Passport", "captured_at": "2026-07-03T08:09:02Z", "url": "https://acme.thirdfactor.ai/v3/media/kyc/recordings/screenshots/frame.jpg" }
]
}],
"reviews": []
},
"risk": null,
"tags": ["high-value"],
"credits_charged": 4.0,
"callback_url": "https://partner.example.com/kyc/done",
"metadata": { "internal_ref": "acct-88213" },
"expires_at": "2026-07-04T08:00:00Z",
"created_at": "2026-07-03T08:00:00Z",
"completed_at": null,
"decision_reason": "Flagged for manual review",
"decline_reason": "",
"review_notes": "",
"workflow_name": "Standard KYC",
"workflow_version": 3,
"verification_type": "KYC",
"individual_id": "0b6f1c9e-2d4a-4e8b-9f3c-5a7d1e2b4c68",
"started_at": "2026-07-03T08:01:05Z",
"identity": {
"full_name": "Jane Doe", "full_name_en": null, "date_of_birth": "1990-01-15",
"nationality": "NP", "document_type": "passport", "document_number": "PA0123456",
"email": "[email protected]", "email_verified": true, "phone": null, "phone_verified": false,
"…": "…"
},
"modules": [
{
"node_id": "ID_VERIFICATION", "feature": "ID_VERIFICATION", "label": "ID Verification",
"status": "approved", "score": 92, "warnings": [],
"started_at": "2026-07-03T08:02:10Z", "completed_at": "2026-07-03T08:02:14Z",
"data": { "document_id": "3c1d9a4e-…", "fields": { "full_name": "Jane Doe" }, "front_image_url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/passport.jpg" },
"step_status": "completed", "version": null, "edited": false
},
{
"node_id": "QUESTIONNAIRE", "feature": "QUESTIONNAIRE", "label": "Questionnaire",
"status": "approved", "score": 100, "warnings": [],
"started_at": "2026-07-03T08:03:02Z", "completed_at": "2026-07-03T08:04:40Z",
"data": { "answers": { "evidence": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/scan.jpg" }, "…": "…" },
"step_status": "completed", "version": 2, "edited": true
},
{
"node_id": "AML_SCREENING", "feature": "AML_SCREENING", "label": "AML Screening",
"status": "in_review", "score": 6, "warnings": ["watchlist_match", "sanctions_match"],
"started_at": "2026-07-03T08:09:40Z", "completed_at": "2026-07-03T08:09:41Z",
"data": { "risk_level": "high", "matches": [{ "name": "Jane Doe", "lists": ["sanctions"], "score": 0.94 }], "possible_matches": [] },
"step_status": "completed", "version": null, "edited": false
}
],
"media": [
{
"kind": "document", "side": "front", "document_type": "passport",
"feature": "ID_VERIFICATION", "node_id": "ID_VERIFICATION", "source_id": "3c1d9a4e-…",
"url": "https://acme.thirdfactor.ai/v3/media/kyc/documents/front/passport.jpg",
"content_type": "image/jpeg", "captured_at": "2026-07-03T08:02:09Z"
},
{
"kind": "selfie", "feature": "LIVENESS", "node_id": "LIVENESS", "source_id": "7d2e4f60-…",
"url": "https://acme.thirdfactor.ai/v3/media/kyc/faces/selfie.jpg",
"content_type": "image/jpeg", "captured_at": "2026-07-03T08:05:30Z"
}
],
"network": {
"ip_addresses": [{ "ip": "203.0.113.5", "country": "NP", "city": "Kathmandu", "is_vpn": false, "is_tor": false, "abuse_score": 2, "precise_latitude": 27.7172, "precise_longitude": 85.324, "…": "…" }],
"precise_location": { "latitude": 27.7172, "longitude": 85.324, "accuracy_m": 12.0, "address": "Thamel, Kathmandu", "captured_at": "2026-07-03T08:01:10+00:00" },
"clients": [{ "role": "mobile", "label": "iPhone · Safari", "ip_address": "203.0.113.5", "user_agent": "Mozilla/5.0 (iPhone; …)", "…": "…" }]
},
"duplicates": [],
"allow_back": true,
"back_count": 2,
"edited": true
}stagestringHuman-readable lifecycle milestone (e.g. Started, Case Created,
Approved) — the same value GET /v3/session/<id>/checklist/ returns.
workflowstringnull.decision_v3objectStructured per-check breakdown across id_verifications, liveness_checks,
face_matches, aml_screenings, reviews, and (when the workflow enables
them) nfc_verifications, poa_verifications, phone_verifications,
email_verifications, ip_analyses, database_validations,
questionnaire_responses, and confirm_details, plus video_calls (always
present; empty when the session had no agent video call). Evidence files
appear as absolute *_url values — see Evidence media.
decision_v3.video_callsarrayOne entry per agent video call: id, status (waiting, ringing,
active, completed, abandoned), verdict (pass / fail / null),
verdict_reason, duration_s, completed_at, recording_url,
recording_status, and screenshots[] (label, captured_at, url).
The agent's identity and internal notes are not included.
riskobjectnull.tagsarraycredits_chargednumbermetadataobjectcompleted_atstringISO 8601 timestamp the session reached a terminal status, or null while
still running.
decisionobjectThe per-node decision snapshot recorded when the session finished, keyed by
node id (the feature key for single-node features):
{feature, status, score, warnings, data} per module, evidence as URLs.
Empty until then.
decision_reasonstringWhy the session ended as it did — All checks passed, One or more checks failed, Flagged for manual review, a duplicate-identity match, or the
reviewer's reason. decline_reason and review_notes carry a reviewer's
decline code and notes.
modulesarrayEvery node of the workflow in order, with its module result: node_id,
feature, label, status, score, warnings, started_at,
completed_at and the full data (evidence as URLs). Covers every
feature — including ones decision_v3 has no array for (age estimation,
document AI, signature, product selection, branch visit, …) and any module
added later — and reads the live results, so a verdict recorded after the
session finished is current. A node that never ran is not_started.
Each row also carries (additive, from Back navigation):
step_status — the node's place on the applicant's path: completed
(finished on the path), active (on the path and still to do, or running
again after an applicant change), superseded (finished, then a branch
change after an applicant edit took it off the path — its result was reset,
the earlier data is kept for audit) or skipped (never on the applicant's
path: a branch not taken, the contact channel they didn't pick);
version — the data version of an input step (questionnaire, product
selection, branch visit, confirm details): 1 as first submitted, plus one
for each accepted change, null for every other step (ID, liveness, AML, …)
and for input steps not submitted yet; edited — true when the applicant
changed the step after first submitting it. (A later questionnaire whose
read-only answers are prefilled from a changed step gets them re-derived:
a new version and "edited": true as well.)
allow_backbooleanWhether the session started with the workflow's Allow applicants to go
back setting on. Pinned when the session is created, so changing the
workflow later doesn't affect a session in progress. Default true.
back_countintegerHow many times the applicant pressed Back. 0 when they never went back.
editedbooleantrue once the applicant changed at least one step after first submitting
it — the modules rows with "edited": true show where. Continuing a step
with nothing changed doesn't count.
mediaarrayEvery evidence file the session holds, oldest first — see
Evidence media. Each entry: kind, feature, node_id,
url, content_type, captured_at, source_id; documents add side and
document_type, stills add label, videos add status.
identityobjectThe identity read so far — name (plus full_name_en, the romanized name
for a non-Latin document), date of birth, nationality, document type /
number / country / dates, email and phone with *_verified flags, address.
networkobjectip_addresses[] — this session's IP analysis (geo-IP, VPN / proxy / Tor /
hosting flags, ISP, abuse score and the precise GPS captured alongside);
precise_location — the browser GPS the applicant shared, when the workflow
asked for it; clients[] — every device that touched the session, both ends
of a desktop → phone handoff.
duplicatesarray1:N duplicate-identity hits this session raised: matched_individual_id,
matched_external_user_id, face score, corroborating signals, sources
and the review status (potential / confirmed / dismissed).
workflow_namestringAlso workflow_version (the published version it ran on),
verification_type (KYC / KYB), individual_id and started_at.
The list endpoint returns the lighter object (no modules, media,
identity, network, duplicates, allow_back, back_count, edited).
Errors
| Status | Code | When |
|---|---|---|
401 | invalid API key | Missing or wrong x-api-key. |
403 | api_key_tenant_mismatch / unknown_tenant_host | The key is used on another tenant's base URL, or the base URL belongs to no tenant — see Authentication. |
404 | Session not found. | Unknown ID, or the ID belongs to another tenant. |
429 | rate-limited | Over the kyc_v3_read bucket. |
Evidence media
GET /v3/media/<path>decision, decision_v3, modules and media never carry file bytes or
base64. Every evidence file is an absolute URL on this API, fetched with the
same x-api-key. media lists every file the session holds in one place
(document sides, selfie, liveness video and stills, reference images, the
signature capture, video-call recording and stills), each with its kind, the
module that captured it and its content_type; inside module data the URLs sit
next to what they belong to:
| Module | Keys |
|---|---|
| Any module's document — ID, proof of address, business documents, document AI | front_image_url, back_image_url, portrait_image_url beside each document_id |
| Liveness | selfie_url; event_stills[].url for each active-liveness event |
| Age estimation | selfie_url — the capture the estimate was read from |
| Signature | signature_image_url — the signature captured in this session |
| Face match | source_image_url (document face), target_image_url (selfie); reference.image_url when you supplied a reference portrait |
| Questionnaire file field | the answer is the URL; the file id stays on questions[].answer_file_id |
| Confirm details | the confirmed ID document's document_id and image URLs |
| Liveness (PASSIVE_VIDEO) | video_url + video_status — the selfie clip as WebM |
| Video call | video_calls[].recording_url + recording_status (WebM); video_calls[].screenshots[].url |
- URLs are stable — safe to store and fetch later. They only resolve for
your own tenant's evidence; anything else is a
404. - The file bytes are always served directly by this API (
200), never as a redirect to another host, so yourx-api-keystays on the API origin. - Before sending
x-api-keyto a media URL, check that its host matches the API host you call/v3/session/on; don't send the key anywhere else. - A file stops resolving once evidence retention purges it.
- Sessions that ran active liveness before evidence URLs were introduced keep
their
event_stillslabels without aurl. - Video is converted after capture. Every video URL is WebM (VP8 video,
Opus audio when the source had audio), produced in the background once the
clip or call recording is stored. Until then the URL is
nulland its status (video_status/recording_status) ispending; poll the session again and fetch once it isready.failedmeans no WebM could be produced and the URL staysnull— the verification outcome is never affected.video_statusis omitted when no clip was captured;recording_statusisnullwhen the call has no recording (recording turned off, or a call from before WebM conversion existed). - Video-call stills captured before they were stored as files are not listed
in
screenshotsuntil your operator runs the evidence backfill. - Fetches use their own
kyc_v3_mediarate bucket, so downloading a session's images doesn't eat into your decision polls.
Warning
A questionnaire file answer used to be the uploaded file's id. It is now the
file URL; read questions[].answer_file_id if you stored the id.
curl -s "https://acme.thirdfactor.ai/v3/media/kyc/faces/selfie.jpg" \
-H "x-api-key: $OBSIDIAN_API_KEY" -o selfie.jpg
# video: same key, same host — only once its status is "ready"
curl -s "https://acme.thirdfactor.ai/v3/media/kyc/recordings/webm/kyc-7f2c.webm" \
-H "x-api-key: $OBSIDIAN_API_KEY" -o call.webmDecision summary
GET /v3/session/<session_id>/decision/A compact decision summary — the cheapest read when you only need the verdict, not the full evidence breakdown.
curl -s "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b/decision/" \
-H "x-api-key: $OBSIDIAN_API_KEY"const resp = await fetch(
`https://acme.thirdfactor.ai/v3/session/${sessionId}/decision/`,
{ headers: { "x-api-key": process.env.OBSIDIAN_API_KEY } },
);
const { decision, status } = await resp.json();resp = requests.get(
f"https://acme.thirdfactor.ai/v3/session/{session_id}/decision/",
headers={"x-api-key": OBSIDIAN_API_KEY},
)
decision = resp.json()["decision"]Response Example
{
"session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"status": "approved",
"decision": { "ID_VERIFICATION": { "feature": "ID_VERIFICATION", "status": "approved", "score": 92, "warnings": [], "data": { "…": "…" } } },
"decision_v3": {
"id_verifications": [],
"liveness_checks": [],
"face_matches": [],
"aml_screenings": [],
"video_calls": [],
"reviews": []
},
"reason": ""
}statusstringapproved, declined, in_review, …).decisionobjectThe per-node decision snapshot recorded when the session finished, keyed by
node id: {feature, status, score, warnings, data} per module, evidence as
URLs. Empty until the session finishes.
decision_v3objectStructured per-check breakdown — the same arrays, with the same evidence URLs, as Get session.
reasonstringWarning
Any decision shown to the browser or SDK is advisory. Confirm the final
outcome from the signed webhook or this server-side
GET /v3/session/<id>/decision/ call before granting access. See
Webhooks → Signatures.
Errors
| Status | Code | When |
|---|---|---|
401 | invalid API key | Missing or wrong x-api-key. |
404 | Session not found. | Unknown ID, or the ID belongs to another tenant. |
429 | rate-limited | Over the kyc_v3_read bucket. |
Manage a session
| Endpoint | Purpose |
|---|---|
PATCH | POST /v3/session/<id>/update-status/ | Override the decision. |
DELETE | POST /v3/session/<id>/delete/ | Soft-delete the session and its evidence. Returns 204. |
GET /v3/session/<id>/logs/ | Chronological event timeline. |
GET /v3/session/<id>/checklist/ | Evidence checklist plus stage. |
GET | POST | DELETE /v3/session/<id>/tags/ | List / apply / remove a trust-layer tag. |
GET /v3/session/<id>/generate-pdf | Verification report as application/pdf (no trailing slash). |
Override status
PATCH /v3/session/<id>/update-status/
POST /v3/session/<id>/update-status/statusstringbodyrequiredTarget status. Must be a valid session status, else 400.
reasonstringbodyOptional note stored with the override.
curl -X PATCH "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-.../update-status/" \
-H "x-api-key: $OBSIDIAN_API_KEY" -H "Content-Type: application/json" \
-d '{ "status": "approved", "reason": "Manual review passed" }'await fetch(`https://acme.thirdfactor.ai/v3/session/${sessionId}/update-status/`, {
method: "PATCH",
headers: { "x-api-key": process.env.OBSIDIAN_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ status: "approved", reason: "Manual review passed" }),
});requests.patch(
f"https://acme.thirdfactor.ai/v3/session/{session_id}/update-status/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"status": "approved", "reason": "Manual review passed"},
)Response is the full updated session object (same shape as Read a session).
Warning
This overrides the automated decision — use it for a human review outcome,
not to "retry" a failed automated check. Overriding a session does not re-run
any module; the underlying evidence (decision_v3) is unchanged.
Errors
| Status | Code | When |
|---|---|---|
400 | Invalid status. | status isn't a valid SessionStatus value. |
401 | invalid API key | Missing or wrong x-api-key. |
404 | Session not found. | Unknown ID, or the ID belongs to another tenant. |
429 | rate-limited | Over the kyc_v3_write bucket. |
Delete a session
DELETE /v3/session/<id>/delete/
POST /v3/session/<id>/delete/Soft-deletes the session and its evidence. Returns 204 No Content (or 404
if the ID doesn't resolve for this tenant).
curl -X DELETE "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-.../delete/" \
-H "x-api-key: $OBSIDIAN_API_KEY"Note
Soft-deleted sessions are excluded from list/read/decision calls immediately but retained internally for audit/compliance purposes — deletion is not a way to permanently erase applicant data. Contact support for a hard-delete request.
Logs
GET /v3/session/<id>/logs/curl -s "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-.../logs/" \
-H "x-api-key: $OBSIDIAN_API_KEY"resp = requests.get(
f"https://acme.thirdfactor.ai/v3/session/{session_id}/logs/",
headers={"x-api-key": OBSIDIAN_API_KEY},
)Response Example
{
"session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"logs": [
{
"event": "session.created",
"source": "api",
"message": "Session created",
"detail": {},
"created_at": "2026-07-03T08:00:00Z"
},
{
"event": "tag.added",
"source": "api",
"message": "Tag applied via API: high-value",
"detail": { "tags": ["high-value"] },
"created_at": "2026-07-03T08:05:00Z"
},
{
"event": "video_call.notice",
"source": "review",
"message": "Manual reminder sent by SMS",
"detail": { "kind": "manual", "channel": "sms", "sms": true, "billed": true,
"by": "[email protected]", "reviewer": "[email protected]" },
"created_at": "2026-07-03T09:30:00Z"
}
]
}eventstringsession.created, tag.added.sourcestringapi (your integration), user / verification (the applicant and the checks in the hosted flow), system (automatic platform steps — scheduled automations, reminders, sweeps), review (a console operator, named in detail.reviewer).messagestringdetailobjectNote
Actions a console operator takes by hand are logged with source: "review"
and the operator's email in detail.reviewer, like manual review decisions.
That includes a session created from the console (session.created), a
video-call reminder sent by hand (video_call.notice), an automation sent
from the console (automation.executed) and a reference image an operator
sourced (reference.sourced). The same events keep source: "system" (or
"api" for sessions your integration creates) when no operator was involved.
Before 2026-09-30 these operator rows were logged as system (and console-created
sessions as api), so match on event together with source, not on
source alone.
Checklist
GET /v3/session/<id>/checklist/Returns the evidence checklist plus the current stage, useful for rendering
progress in your own dashboard without polling the full session object.
{
"session_id": "6f0e2b7a-1c2d-4e5f-8a9b-0c1d2e3f4a5b",
"stage": "Case Created",
"checklist": [
{ "item": "identity_document", "status": "complete" },
{ "item": "liveness", "status": "complete" },
{ "item": "face_match", "status": "complete" },
{ "item": "aml_screening", "status": "pending" }
]
}Tags
GET /v3/session/<id>/tags/ # list applied tags
POST /v3/session/<id>/tags/ # apply a tag
DELETE /v3/session/<id>/tags/ # remove a tagPOST and DELETE take a tag body:
{ "tag": "high-risk" }curl -X POST "https://acme.thirdfactor.ai/v3/session/6f0e2b7a-.../tags/" \
-H "x-api-key: $OBSIDIAN_API_KEY" -H "Content-Type: application/json" \
-d '{ "tag": "high-risk" }'requests.post(
f"https://acme.thirdfactor.ai/v3/session/{session_id}/tags/",
headers={"x-api-key": OBSIDIAN_API_KEY},
json={"tag": "high-risk"},
)POST responds { "applied": true, "tag": "high_risk" } (tags are
lower-cased and space-normalized). DELETE responds { "removed": true }.
GET responds { "session_id": "...", "tags": ["high_risk"] }. Tags are part
of the trust layer and are visible in the console.
Errors
| Status | Code | When |
|---|---|---|
400 | tag_required | POST with an empty/missing tag. |
404 | Session not found. | Unknown ID, or the ID belongs to another tenant. |
Generate PDF
GET /v3/session/<id>/generate-pdfReturns the verification report as an application/pdf attachment (binary
stream). Note there is no trailing slash on this endpoint.
curl -s "https://acme.thirdfactor.ai/v3/session/<id>/generate-pdf" \
-H "x-api-key: $OBSIDIAN_API_KEY" \
-o verification-report.pdfresp = requests.get(
f"https://acme.thirdfactor.ai/v3/session/{session_id}/generate-pdf",
headers={"x-api-key": OBSIDIAN_API_KEY},
)
with open("verification-report.pdf", "wb") as f:
f.write(resp.content)Webhook events
Terminal KYC sessions emit the standard signed webhook envelope with these
event_type values:
identity.kyc.session.approvedidentity.kyc.session.declinedidentity.kyc.session.reviewidentity.kyc.session.abandonedidentity.kyc.session.expired
Each delivery carries the session's core fields plus session — the same
object Get session returns — so a handler has the full
module data, evidence URLs, identity and risk without calling back.
Flow-spawned sessions additionally advance the parent application, so the usual
identity.application.* events still fire. See
Webhooks → Events.
Common pitfalls
Warning
Polling instead of webhooking. GET /v3/session/<id>/decision/ is cheap,
but polling it every second from launch to completion wastes your kyc_v3_read
quota and adds latency the applicant feels. Register a webhook and poll only
as a fallback for missed deliveries.
Warning
Trusting the callback_url redirect. The hosted flow redirects the
browser to callback_url with query params when the applicant finishes — that
redirect is client-controlled and not signed. Treat it as a UX signal
("show a spinner while we confirm") and always re-verify server-side.
Warning
Retrying without Idempotency-Key. A dropped response on
POST /v3/session/ looks identical to a failed create — without the header, a
naive retry mints a second session and debits credits twice.