Reference
Verify a user, end to end
A complete integration — create a session, run the flow, handle the webhook, grant access.
The whole integration is three moving parts: a create call on your server, a redirect (or SDK launch) for the user, and a signed webhook handler. Here it is in full — every payload, every language, the reconciliation sweep most integrations skip, and the edge cases that show up once you have real traffic.
Architecture at a glance
Create a session (server)
POST /v3/session/ with your secret API key. Store the returned id mapped to
your user, then hand the returned url to the client.
Run the flow (client)
Redirect to the hosted url, or open it in-app with an SDK. The server enforces
the workflow's modules and liveness method — it cannot be downgraded client-side.
Handle the webhook (server)
Verify the signature, then act on identity.kyc.session.*. This is the real
gate for granting access.
Reconcile (server, scheduled)
Sweep any session still pending after N minutes and re-read its decision, in case a webhook was missed.
Redirect vs. SDK: which delivery mechanism
Both paths create the identical session and run the identical server-side engine — this is a delivery decision, not a capability trade-off (except NFC).
| Full-page redirect | Embedded SDK | |
|---|---|---|
| Integration effort | Lowest — one fetch, one window.location.href | A few more lines: install the SDK, handle a lifecycle result |
| Feels native inside your app | No — full navigation away | Yes — modal/embedded, no navigation |
| Outcome delivery | callback_url redirect | Bridge event (ready/completed/error/close) and webhook |
| NFC passport chip read | Not available | Available on iOS/Android/Flutter only |
| Best for | Emailed/SMS links, QR codes, fastest MVP | In-app onboarding, mobile apps needing NFC |
Info
Whichever you choose, the webhook is still the only authoritative signal. See Hosted flow overview for the full delivery comparison and SDKs for the embed API.
1. Create the session
curl -X POST "$TF_BASE_URL/v3/session/" \
-H "x-api-key: $TF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: kyc-user-42" \
-d '{
"workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
"vendor_data": "user-42",
"callback_url": "https://app.example.com/kyc/done",
"contact_email": "[email protected]",
"expires_in_hours": 24,
"prefill": { "full_name": "Aarav Sharma", "nationality": "NP" }
}'import { NextResponse } from "next/server";
export async function POST(req: Request) {
const user = await requireUser(req); // your auth
const res = await fetch(`${process.env.TF_BASE_URL}/v3/session/`, {
method: "POST",
headers: {
"x-api-key": process.env.TF_API_KEY!, // secret — server only
"content-type": "application/json",
"idempotency-key": `kyc-${user.id}`, // retries are free
},
body: JSON.stringify({
workflow_id: process.env.TF_WORKFLOW_ID, // pin it explicitly
vendor_data: user.id, // → identity.external_user_id
callback_url: `${process.env.APP_URL}/kyc/done`,
contact_email: user.email,
expires_in_hours: 24,
prefill: { full_name: user.fullName, nationality: user.nationality },
}),
});
if (res.status === 402) {
// insufficient_credits — degrade gracefully, don't 500 at the user
return NextResponse.json({ error: "verification_unavailable" }, { status: 503 });
}
if (!res.ok) throw new Error(`obsidian ${res.status}: ${await res.text()}`);
const session = await res.json();
await db.kycSessions.upsert({ userId: user.id, sessionId: session.id, status: "pending" });
return NextResponse.json({ url: session.url, id: session.id });
}import os, requests
from flask import Blueprint, jsonify, g
bp = Blueprint("kyc", __name__)
@bp.post("/api/kyc/start")
def start_kyc():
user = g.current_user # your auth
resp = requests.post(
f"{os.environ['TF_BASE_URL']}/v3/session/",
headers={
"x-api-key": os.environ["TF_API_KEY"],
"Idempotency-Key": f"kyc-{user.id}",
},
json={
"workflow_id": os.environ["TF_WORKFLOW_ID"],
"vendor_data": str(user.id),
"callback_url": f"{os.environ['APP_URL']}/kyc/done",
"contact_email": user.email,
"expires_in_hours": 24,
"prefill": {"full_name": user.full_name, "nationality": user.nationality},
},
timeout=15,
)
if resp.status_code == 402:
return jsonify({"error": "verification_unavailable"}), 503
resp.raise_for_status()
session = resp.json()
db.kyc_sessions.upsert(user_id=user.id, session_id=session["id"], status="pending")
return jsonify({"url": session["url"], "id": session["id"]})The 201 response looks like this — store id, hand url to the client:
{
"id": "6f0e2b7a-9c1a-4b1e-8f2a-1f0e2b7a9c1a",
"session_kind": "user",
"status": "not_started",
"url": "https://v3.thirdfactor.ai/verify/<session_token>",
"vendor_data": "user-42",
"decision": "in_progress",
"expires_at": "2026-07-04T08:00:00Z",
"created_at": "2026-07-03T08:00:00Z"
}Tip
Store the mapping session.id → your user before you hand the URL out. You
also passed vendor_data: user.id, which comes back on every webhook — either
lookup works, but having the row already exist keeps the webhook handler simple.
Note
Passing an Idempotency-Key header makes create safe to retry: a repeat with
the same key returns the original session (201) and is never charged twice.
decision on the create response is always in_progress — the applicant
hasn't started yet. Never treat it as an outcome.
2. Send the user in
const { url } = await fetch("/api/kyc/start", { method: "POST" }).then((r) => r.json());
// Simplest — full-page redirect:
window.location.href = url;
// Or in-app, with the web SDK:
import { ThirdFactor } from "@thirdfactor/web";
const result = await ThirdFactor.verify({ url });
router.push(result.status === "approved" ? "/kyc/pending" : "/kyc/status");Note the destination on approval: /kyc/pending, not /kyc/success. The client
says the applicant finished; it does not say you may act. Show
"we're confirming your details" and let the webhook flip the state.
3. Handle the webhook
KYC session events carry a flat payload — decision, vendor_data, and
session_id are top-level, with no nested session object:
{
"session_id": "6f0e2b7a-9c1a-4b1e-8f2a-1f0e2b7a9c1a",
"status": "declined",
"decision": "declined",
"decision_reason": "document_check_failed",
"vendor_data": "user-42",
"workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
"application_id": null,
"created_at": "2026-07-03T08:00:00Z",
"completed_at": "2026-07-03T08:06:12Z",
"event_type": "identity.kyc.session.declined",
"idempotency_key": "b2a1c3d4-…"
}app.post("/webhooks/obsidian", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.header("X-Webhook-Signature") ?? "";
if (!verify(process.env.TF_WEBHOOK_SECRET!, sig, req.body)) {
return res.status(400).send("bad signature"); // verify before parsing
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // ack fast
if (await seen(event.idempotency_key)) return; // dedupe: retries happen
switch (event.event_type) {
case "identity.kyc.session.approved":
await activateUser(event.vendor_data); // ← the real gate
break;
case "identity.kyc.session.declined":
await rejectUser(event.vendor_data, event.session_id);
break;
case "identity.kyc.session.review":
await markPending(event.vendor_data); // expect a later terminal event
break;
case "identity.kyc.session.expired":
await inviteRetry(event.vendor_data);
break;
default:
await markPending(event.vendor_data); // unknown → treat as review
}
});@bp.post("/webhooks/obsidian")
def handle_webhook():
sig = request.headers.get("X-Webhook-Signature", "")
raw = request.get_data() # raw bytes, before parsing
if not verify(os.environ["TF_WEBHOOK_SECRET"], sig, raw):
return "bad signature", 400
event = request.get_json()
if seen(event["idempotency_key"]):
return "", 200 # ack, already processed
match event["event_type"]:
case "identity.kyc.session.approved":
activate_user(event["vendor_data"])
case "identity.kyc.session.declined":
reject_user(event["vendor_data"], event["session_id"])
case "identity.kyc.session.review":
mark_pending(event["vendor_data"])
case "identity.kyc.session.expired":
invite_retry(event["vendor_data"])
case _:
mark_pending(event["vendor_data"]) # unknown → treat as review
return "", 200The verify helper is on the signatures page.
4. Reconcile (the part everyone skips)
Webhooks are reliable, not infallible — your endpoint might have been down for an
hour. Run a sweep that re-reads any session still pending after, say, 30
minutes:
for (const row of await db.kycSessions.stalePending({ olderThanMinutes: 30 })) {
const d = await fetch(`${BASE}/v3/session/${row.sessionId}/decision/`, {
headers: { "x-api-key": KEY },
}).then((r) => r.json());
if (d.decision !== "in_progress") await applyDecision(row.userId, d);
}GET /v3/session/<id>/decision/ returns:
{
"session_id": "6f0e2b7a-9c1a-4b1e-8f2a-1f0e2b7a9c1a",
"status": "approved",
"decision": "approved",
"decision_v3": { "id_verifications": [], "liveness_checks": [], "face_matches": [], "aml_screenings": [], "reviews": [] },
"reason": ""
}Sweep on a schedule, not in a tight poll loop — /v3 reads are rate-limited per
tenant.
Handling review properly
in_review is the state most integrations get wrong. It is neither a failure nor
a success: an operator is looking at the session in Console → Verifications,
and a terminal .approved or .declined event will follow, possibly hours
later. Tell the user honestly ("we're reviewing your documents, we'll email
you"), keep the account in a limbo state, and let the webhook resolve it.