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_idstringbodyKYC workflow UUID from GET /v3/workflows/. Tenant default when omitted —
see Workflows.
vendor_datastringbodyYour external user reference. Becomes the linked identity's external_user_id.
callback_urlstringbodyWhere the applicant is redirected after a terminal state.
expires_in_hoursnumberbodyDefault: 48Hosted 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.
| Status | Terminal? | Meaning |
|---|---|---|
not_started | no | Created; the applicant hasn't opened the URL. |
in_progress | no | The applicant is mid-flow. |
awaiting_user | no | Waiting on the applicant (e.g. a resubmission or a scheduled video call). |
in_review | no | Held for an operator decision. |
resubmitted | no | The applicant was asked to retry and did. |
approved | yes | Verification passed. |
declined | yes | Verification failed. |
abandoned | yes | Closed without completing. |
expired | yes | The hosted URL lapsed before completion. |
kyc_expired | yes | A 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:
idstringsession_kindstringuser.statusstringstagestringurlstringvendor_datastringworkflowstringdecisionstringdecision_v3objectriskobjecttagsarraycredits_chargednumbercallback_urlstringmetadataobjectexpires_atstringcreated_atstringcompleted_atstringSession management endpoints
| Endpoint | Purpose |
|---|---|
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-pdf | Verification 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 by | POST /v3/session/ | POST /api/v1/sessions/ (signed JWT) |
| Represents | One KYC pass | A whole onboarding journey |
| Can contain | Modules only | Forms, product selection, one or more KYC sessions, operator review |
| Correlator | vendor_data → identity external_user_id | external_ref |
| Stable outcome field | decision | application.decision |
| Failed-outcome value | declined | rejected |
| Read endpoint | GET /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
abandonedsession may hold partial module results (e.g. a document was captured but liveness never ran). Checkdecision_v3per category rather than assuming an all-or-nothing result — an abandoned session is stillabandonedas 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.
statusisnot_startedanddecisionisin_progressuntil 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 return400, 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.