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 forStandalone identity verificationMulti-step onboarding flows
Auth on createRaw API keyPartner-signed JWT (API key signs it)
Decision contractdecision + decision_v3 breakdownStable decision (5 values)
Custom form fields❌ — KYC modules only✅ — flow-defined fields
Risk list matching✅ — flow blocklist steps
Per-module results✅ — full snapshotVia 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/json
workflow_idstringbody

KYC workflow UUID (from GET /v3/workflows/). The tenant default workflow is used when omitted.

session_kindstringbodyDefault: user

Currently only user is accepted.

vendor_datastringbody

Your external user reference. Doubles as the linked identity's external_user_id.

callback_urlstringbody

Where the applicant is redirected after completion.

contact_emailstringbody

Applicant email; used for OTP and notifications.

contact_phonestringbody

Applicant phone; used for OTP and notifications.

expires_in_hoursintegerbodyDefault: 48

Hosted-URL lifetime, 1..720.

localestringbodyDefault: en

UI language for the hosted flow.

metadataobjectbody

Free-form JSON you want echoed back on session reads. Not shown to the applicant.

prefillobjectbody

Applicant 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.

referenceobjectbody

Optional 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"
}
idstring
The session UUID.
session_kindstring
Session kind, e.g. user.
statusstring
Lifecycle status; starts at not_started.
urlstring

Absolute hosted-flow URL on the tenant's domain. Redirect the end user here.

vendor_datastring
Echoes the external reference you supplied.
decisionstring
Compact decision; starts at in_progress.
expires_atstring
Hosted-URL expiry (ISO 8601).
created_atstring
Session creation time (ISO 8601).

Redirect 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

StatusCodeWhen
400validation error (field-specific message)expires_in_hours outside 1..720, malformed contact_email, etc.
401invalid API keyMissing or wrong x-api-key.
402insufficient_creditsTenant balance can't cover the SESSION_CREATE fee.
404workflow_id not found.workflow_id doesn't exist for this tenant.
429rate-limitedOver the kyc_v3_session_create bucket.
500internalThe credit debit failed for a reason other than an insufficient balance.

List sessions

GET /v3/session/          # bare array, newest first (alias: GET /v3/sessions)
statusstringquery

Filter by lifecycle status. An unknown value returns 400 invalid_status.

session_kindstringquery

Filter by session kind. An unknown value returns 400 invalid_session_kind.

pageintegerqueryDefault: 1

Page number.

page_sizeintegerqueryDefault: 50

Items per page, clamped to a max of 200.

The response body stays a bare JSON array. Totals and paging ride in response headers:

HeaderMeaning
X-Total-CountTotal matching sessions.
X-PageCurrent page.
X-Page-SizePage 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

StatusCodeWhen
400invalid_statusstatus query param isn't a known lifecycle status.
400invalid_session_kindsession_kind query param isn't a known kind.
401invalid API keyMissing or wrong x-api-key.
429rate-limitedOver the kyc_v3_read bucket.

Read a session

GET /v3/session/<session_id>/      # full session + module results

Returns 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
}
stagestring

Human-readable lifecycle milestone (e.g. Started, Case Created, Approved) — the same value GET /v3/session/<id>/checklist/ returns.

workflowstring
The workflow UUID this session ran, or null.
decision_v3object

Structured 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.

riskobject
The session's risk score object, when the trust layer has scored it, else null.
tagsarray
Trust-layer tags currently applied to this session.
credits_chargednumber
Total credits this session has debited so far.
metadataobject
Echoes back whatever you passed at creation.
completed_atstring

ISO 8601 timestamp the session reached a terminal status, or null while still running.

Errors

StatusCodeWhen
401invalid API keyMissing or wrong x-api-key.
404Session not found.Unknown ID, or the ID belongs to another tenant.
429rate-limitedOver 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": ""
}
statusstring
Lifecycle status.
decisionstring
Compact decision, e.g. approved.
decision_v3object

Structured per-check breakdown across id_verifications, liveness_checks, face_matches, aml_screenings, and reviews.

reasonstring
Human-readable reason, when present.

Warning

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

StatusCodeWhen
401invalid API keyMissing or wrong x-api-key.
404Session not found.Unknown ID, or the ID belongs to another tenant.
429rate-limitedOver the kyc_v3_read bucket.

Manage a session

EndpointPurpose
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-pdfVerification report as application/pdf (no trailing slash).

Override status

PATCH /v3/session/<id>/update-status/
POST  /v3/session/<id>/update-status/
statusstringbodyrequired

Target status. Must be a valid session status, else 400.

reasonstringbody

Optional 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

StatusCodeWhen
400Invalid status.status isn't a valid SessionStatus value.
401invalid API keyMissing or wrong x-api-key.
404Session not found.Unknown ID, or the ID belongs to another tenant.
429rate-limitedOver 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"
    }
  ]
}
eventstring
Machine event key, e.g. session.created, tag.added.
sourcestring
What produced the event: api, hosted_flow, console, etc.
messagestring
Human-readable description.
detailobject
Structured event payload, event-dependent.

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 tag

POST 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

StatusCodeWhen
400tag_requiredPOST with an empty/missing tag.
404Session not found.Unknown ID, or the ID belongs to another tenant.

Generate PDF

GET /v3/session/<id>/generate-pdf

Returns 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.pdf
resp = 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.approved
  • identity.kyc.session.declined
  • identity.kyc.session.review
  • identity.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.

FAQ