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": "..." }).
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": "in_progress",
"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": "approved",
"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 object, including a per-module results breakdown. 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": "in_progress",
"decision_v3": {
"id_verifications": [{ "detected_type": "passport", "is_document_valid": true }],
"liveness_checks": [{ "status": "passed", "method": "PASSIVE" }],
"face_matches": [{ "matched": true, "similarity": 0.94 }],
"aml_screenings": [{ "risk_level": "clear", "matches": [] }],
"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
}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, and
questionnaire_responses.
riskobjectnull.tagsarraycredits_chargednumbermetadataobjectcompleted_atstringISO 8601 timestamp the session reached a terminal status, or null while
still running.
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. |
Decision 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": "approved",
"decision_v3": {
"id_verifications": [],
"liveness_checks": [],
"face_matches": [],
"aml_screenings": [],
"reviews": []
},
"reason": ""
}statusstringdecisionstringapproved.decision_v3objectStructured per-check breakdown across id_verifications, liveness_checks,
face_matches, aml_screenings, and reviews.
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"
}
]
}eventstringsession.created, tag.added.sourcestringapi, hosted_flow, console, etc.messagestringdetailobjectChecklist
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.expired
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.