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": [{
"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
}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.
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. |
Evidence media
GET /v3/media/<path>decision and decision_v3 never carry file bytes or base64. Every evidence
file is an absolute URL on this API, fetched with the same x-api-key:
| Module | Keys |
|---|---|
| ID / proof of address / business documents | front_image_url, back_image_url, portrait_image_url beside each document_id |
| Liveness | selfie_url; event_stills[].url for each active-liveness event |
| 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": "approved",
"decision_v3": {
"id_verifications": [],
"liveness_checks": [],
"face_matches": [],
"aml_screenings": [],
"video_calls": [],
"reviews": []
},
"reason": ""
}statusstringdecisionstringapproved.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"
}
]
}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.