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": [{
      "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
}
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, 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_callsarray

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

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.

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:

ModuleKeys
ID / proof of address / business documentsfront_image_url, back_image_url, portrait_image_url beside each document_id
Livenessselfie_url; event_stills[].url for each active-liveness event
Face matchsource_image_url (document face), target_image_url (selfie); reference.image_url when you supplied a reference portrait
Questionnaire file fieldthe answer is the URL; the file id stays on questions[].answer_file_id
Confirm detailsthe confirmed ID document's document_id and image URLs
Liveness (PASSIVE_VIDEO)video_url + video_status — the selfie clip as WebM
Video callvideo_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 your x-api-key stays on the API origin.
  • Before sending x-api-key to 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_stills labels without a url.
  • 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 null and its status (video_status / recording_status) is pending; poll the session again and fetch once it is ready. failed means no WebM could be produced and the URL stays null — the verification outcome is never affected. video_status is omitted when no clip was captured; recording_status is null when 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 screenshots until your operator runs the evidence backfill.
  • Fetches use their own kyc_v3_media rate 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.webm

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": [],
    "video_calls": [],
    "reviews": []
  },
  "reason": ""
}
statusstring
Lifecycle status.
decisionstring
Compact decision, e.g. approved.
decision_v3object

Structured per-check breakdown — the same arrays, with the same evidence URLs, as Get session.

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