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 redirectEmbedded SDK
Integration effortLowest — one fetch, one window.location.hrefA few more lines: install the SDK, handle a lifecycle result
Feels native inside your appNo — full navigation awayYes — modal/embedded, no navigation
Outcome deliverycallback_url redirectBridge event (ready/completed/error/close) and webhook
NFC passport chip readNot availableAvailable on iOS/Android/Flutter only
Best forEmailed/SMS links, QR codes, fastest MVPIn-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 "", 200

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

Edge cases worth handling deliberately

Common mistakes

FAQ