Concepts

Sessions and applications

Verification sessions and applications — their lifecycle, statuses, session_kind, vendor_data, expiry, and the endpoints to list, read and manage them.

Obsidian has two units of work. Knowing which one you hold saves a lot of confusion.

  • A verification session (/v3) is one pass of a KYC workflow by one person: capture an ID, prove liveness, match the face, screen against watchlists.
  • An application (/api/v1) is a whole onboarding journey — a form, a product choice, an identity step, a decision — that can spawn a verification session under the hood.

Which one do I want?

See the surface comparison in Architecture if you're deciding which API to build against.

Verification sessions (/v3)

A session has a hosted URL, a status, per-module results, an evidence checklist, an event timeline and a PDF report. You create it server-side and redirect the applicant to its url.

curl -X POST "$BASE/v3/session/" \
  -H "x-api-key: $TF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
    "vendor_data": "your-user-reference-123",
    "callback_url": "https://partner.example.com/kyc/done",
    "expires_in_hours": 24
  }'
const res = await fetch(`${BASE}/v3/session/`, {
  method: "POST",
  headers: {
    "x-api-key": process.env.TF_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    workflow_id: "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
    vendor_data: "your-user-reference-123",
    callback_url: "https://partner.example.com/kyc/done",
    expires_in_hours: 24,
  }),
});
const session = await res.json();
// { id, session_kind, status, url, vendor_data, decision, expires_at, created_at }
import os, uuid, requests

resp = requests.post(
    f"{BASE}/v3/session/",
    headers={
        "x-api-key": os.environ["TF_API_KEY"],
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
        "vendor_data": "your-user-reference-123",
        "callback_url": "https://partner.example.com/kyc/done",
        "expires_in_hours": 24,
    },
)
session = resp.json()
{
  "id": "6f0e2b7a-…",
  "session_kind": "user",
  "status": "not_started",
  "url": "https://acme.example.com/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"
}
workflow_idstringbody

KYC workflow UUID from GET /v3/workflows/. Tenant default when omitted — see Workflows.

vendor_datastringbody

Your external user reference. Becomes the linked identity's external_user_id.

callback_urlstringbody

Where the applicant is redirected after a terminal state.

expires_in_hoursnumberbodyDefault: 48

Hosted URL lifetime, 1..720.

Lifecycle and statuses

A session moves from creation, through the applicant's flow, to a terminal state. The raw status is more granular than the stable decision.

StatusTerminal?Meaning
not_startednoCreated; the applicant hasn't opened the URL.
in_progressnoThe applicant is mid-flow.
awaiting_usernoWaiting on the applicant (e.g. a resubmission or a scheduled video call).
in_reviewnoHeld for an operator decision.
resubmittednoThe applicant was asked to retry and did.
approvedyesVerification passed.
declinedyesVerification failed.
abandonedyesClosed without completing.
expiredyesThe hosted URL lapsed before completion.
kyc_expiredyesA previously verified identity aged out.
not_started ─▶ in_progress ─┬─▶ approved
                            ├─▶ declined
                            ├─▶ in_review ─▶ approved / declined
                            └─▶ abandoned
not_started ─▶ expired            (URL lapsed before completion)

Note

Terminal states are approved, declined, abandoned, expired and kyc_expired. Only terminal sessions emit a KYC webhook.

session_kind

session_kind is currently always "user" — a standalone verification of one person. The GET /v3/session/ list endpoint accepts ?session_kind=user; any other value returns 400. Sessions that are spawned by an /api/v1 application flow are linked to their parent application internally and advance it on completion.

vendor_data

vendor_data is your own external user reference. It rides through to the webhook and, importantly, becomes the linked identity's external_user_id — the key you use to correlate an Obsidian identity with your own user record. Set it on create; you can't rely on any other field to join back to your system.

Expiry

The hosted URL has a lifetime set by expires_in_hours on create (1..720, default 48). After it lapses without completion the session goes to expired. Mint a fresh session to let the applicant retry — expiry is on the URL, not a resumable state.

Warning

There is no "resume the same URL" for an expired session. If your product needs applicants to pick up where they left off, mint a new session with the same vendor_data before the old one expires, or catch expired and offer a retry link immediately.

Reading and managing sessions

# Bare JSON array; paging rides in X-Total-Count / X-Page / X-Page-Size headers
curl -H "x-api-key: $TF_API_KEY" \
  "$BASE/v3/session/?status=in_review&page=1&page_size=50"
curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/session/6f0e2b7a-…/"
curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/session/6f0e2b7a-…/decision/"
const res = await fetch(
  `${BASE}/v3/session/?status=in_review&page=1&page_size=50`,
  { headers: { "x-api-key": process.env.TF_API_KEY } }
);
const sessions = await res.json(); // bare array
const total = res.headers.get("X-Total-Count");
resp = requests.get(
    f"{BASE}/v3/session/",
    headers={"x-api-key": os.environ["TF_API_KEY"]},
    params={"status": "in_review", "page": 1, "page_size": 50},
)
sessions = resp.json()  # bare array
total = resp.headers["X-Total-Count"]

GET /v3/session/ accepts ?status= and ?session_kind= filters (an unknown value returns 400) plus ?page= / ?page_size= (max 200, default 50). The body stays a bare JSON array; the total and page ride in the X-Total-Count, X-Page and X-Page-Size response headers.

The full read (GET /v3/session/<id>/) returns the session plus its module results:

idstring
Session UUID.
session_kindstring
Currently always user.
statusstring
Raw status from the table above.
stagestring
Where the applicant is in the flow.
urlstring
The hosted verification URL.
vendor_datastring
Your external user reference.
workflowstring
The workflow that ran.
decisionstring
Stable decision: approved, declined, manual_review, in_progress or abandoned (see Decisions).
decision_v3object
Per-category module result arrays.
riskobject
Trust-layer risk, if scored.
tagsarray
Trust-layer tags applied to the session.
credits_chargednumber
Total credits debited for this session.
callback_urlstring
Post-completion redirect target.
metadataobject
Session metadata.
expires_atstring
Hosted-URL expiry (ISO 8601).
created_atstring
Creation time.
completed_atstring
Terminal time, if reached.

Session management endpoints

EndpointPurpose
PATCH | POST /v3/session/<id>/update-status/Override the decision. Body { "status": "approved", "reason": "…" }; status must be a valid session status (else 400).
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. Body { "tag": "…" }.
GET /v3/session/<id>/generate-pdfVerification report as a binary application/pdf attachment (no trailing slash).

Warning

Overriding a status with update-status/ is an operator-grade action — it sets the authoritative outcome. Use it for back-office resolution, not to paper over a failed flow.

Applications (/api/v1)

An application is created by a hosted session (POST /api/v1/sessions/) with a partner-signed JWT that names a flow_id. It advances through the steps of that flow and carries per-step outputs. Read it with:

GET /api/v1/applications/<application_id>/
{
  "ok": true,
  "application": {
    "id": "ge9vgmlmfbi9k5q",
    "external_ref": "core-banking-id-123",
    "tracking_id": "APP-2026-0001",
    "status": "manual_review",
    "decision": "manual_review",
    "session_expires_at": "2026-05-18T12:00:00Z",
    "session_expiry_state": "active",
    "phone": "9841000000",
    "email": "[email protected]",
    "created_at": "2026-05-18T08:00:00Z",
    "updated_at": "2026-05-18T08:10:00Z"
  }
}

session_expiry_state is one of active, expired or never. You can expire a live hosted URL immediately or reset its expiry with PATCH:

{ "expire_now": true }
{ "session_expires_at": "2026-05-18T12:00:00Z" }
{ "session_expires_at": null }

Hosted application URLs default to the tenant's configured expiry (Console → Settings → API keys → Hosted session expiry, starting at 30 minutes). Existing URLs keep their stored expiry until you change it. See the hosted flow docs for the full create call.

Sessions vs applications at a glance

Verification session (/v3)Application (/api/v1)
Created byPOST /v3/session/POST /api/v1/sessions/ (signed JWT)
RepresentsOne KYC passA whole onboarding journey
Can containModules onlyForms, product selection, one or more KYC sessions, operator review
Correlatorvendor_data → identity external_user_idexternal_ref
Stable outcome fielddecisionapplication.decision
Failed-outcome valuedeclinedrejected
Read endpointGET /v3/session/<id>/GET /api/v1/applications/<id>/

Identities

Verified people are deduplicated into identities (Console → Identities). vendor_data becomes the identity's external_user_id. Identities are what a 1:N face search searches and what the rolling AML re-screen sweep re-checks.

Edge cases

  • Partial data on abandonment. An abandoned session may hold partial module results (e.g. a document was captured but liveness never ran). Check decision_v3 per category rather than assuming an all-or-nothing result — an abandoned session is still abandoned as a whole, but the evidence checklist tells you exactly how far the applicant got.
  • Retrying a create with the same idempotency key. Returns the original session unchanged and is not charged again — see Credits. This is the safe way to handle "did my POST actually go through" after a timeout.
  • Reading a session immediately after creation. status is not_started and decision is in_progress until the applicant opens the URL — there is no result to read yet, this is expected, not an error state.
  • Malformed or unknown filter values. ?status= and ?session_kind= with an unrecognized value return 400, not an empty list — check for that before assuming zero matching sessions.

Warning

Common pitfall: polling GET /v3/session/<id>/ in a tight loop instead of listening for the webhook. Sessions can sit in in_progress for as long as the applicant takes; prefer the webhook for the terminal transition and use polling only to reconcile, not as your primary signal.

FAQ