Documentation

Quickstart

Verify your first user end-to-end in under 10 minutes.

This walks through the shortest path: create a session on your server, send the user through the hosted flow, and read the decision from a webhook. It uses the ThirdFactor Verify API (/v3) — the same surface the SDKs and the hosted-flow console tooling call underneath.

Prerequisites

Get a tenant API key

In the console, go to Settings → API keys and create a key. It is tenant-scoped and secret — keep it server-side. The console shows the raw key once, at creation time; store it in your secrets manager immediately.

Know your base URL

Your Obsidian deployment, e.g. https://v3.thirdfactor.ai. All examples use $TF_BASE_URL and $TF_API_KEY.

Note

Everything below runs against the ThirdFactor Verify API (/v3), authenticated with x-api-key. If you're integrating the partner application flow instead (/api/v1/sessions/, JWT-signed), see Authentication for the equivalent walkthrough — the concepts (session, webhook, decision) are the same either way.

1. Create a verification session

Call POST /v3/session/ from your backend. Never call this from a browser or mobile app — the API key is secret.

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 '{
    "vendor_data": "user-42",
    "callback_url": "https://app.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",
      "email": "[email protected]",
      "phone": "9841000000",
      "nationality": "NP"
    }
  }'
const res = await fetch(`${process.env.TF_BASE_URL}/v3/session/`, {
  method: "POST",
  headers: {
    "x-api-key": process.env.TF_API_KEY!,
    "Content-Type": "application/json",
    "Idempotency-Key": `kyc-${user.id}`,
  },
  body: JSON.stringify({
    vendor_data: user.id,
    callback_url: "https://app.example.com/kyc/done",
    contact_email: user.email,
    expires_in_hours: 24,
    prefill: {
      full_name: user.fullName,
      email: user.email,
      nationality: user.countryCode, // ISO-2
    },
  }),
});

if (!res.ok) {
  const err = await res.json(); // { detail: "insufficient_credits" | ... }
  throw new Error(`session create failed: ${res.status} ${err.detail}`);
}
const session = await res.json(); // { id, url, status, ... }
import os

from thirdfactor import ThirdFactor

# pip install thirdfactor-sdk
tf = ThirdFactor(
    api_key=os.environ["TF_API_KEY"],
    base_url=os.environ["TF_BASE_URL"],
)

session = tf.sessions.create(
    vendor_data=user_id,
    callback_url="https://app.example.com/kyc/done",
    contact_email=user_email,
    expires_in_hours=24,
    prefill={"full_name": full_name, "nationality": "NP"},
    idempotency_key=f"kyc-{user_id}",
)

Response (201):

{
  "id": "6f0e2b7a-3c1a-4e0d-9b2a-7f4c1d2e8a90",
  "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-13T08:00:00Z",
  "created_at": "2026-07-12T08:00:00Z"
}
FieldMeaning
idThe session UUID — use it for GET, update-status, tags, logs, generate-pdf.
urlRedirect the applicant here, or hand it to a client SDK. Absolute URL on your tenant's flow domain.
statusRaw session status. Starts not_started; branch on decision/webhooks for external logic, not this.
decisionin_progress until terminal. Becomes approved / declined / manual_review / expired.
expires_atHosted-URL expiry, derived from expires_in_hours (1..720, default 48).

Creating a session debits the tenant's SESSION_CREATE credit rate immediately — before the applicant does anything. A workflow_id field is also accepted to pick a specific KYC workflow (from GET /v3/workflows/); omit it to use the tenant default.

Warning

Top 3 gotchas that break this call:

  1. Wrong key type. /v3/session/ wants x-api-key: <key> (a raw header value). It is not a bearer token — sending Authorization: Bearer <key> here fails with 401. That format is for the separate /api/v1 partner surface. See Authentication for which surface wants which.
  2. Forgetting Idempotency-Key. Without it, a retried request (timeout, dropped connection, a queue redelivering the job) creates a second session and debits credits twice. Always send a stable, per-attempt key — e.g. derived from your own user ID and a logical action, not a random UUID generated fresh on every retry.
  3. callback_url mismatches. The applicant is redirected back to exactly the callback_url you sent at session creation, with query params appended. If your frontend route doesn't exist, requires auth the redirected browser doesn't have, or differs from what's registered for your domain/CSP, the applicant lands on an error page after completing verification. Test the full redirect round-trip before going live, and treat the redirect as a UX signal only — never as proof of the decision.

2. Send the user through verification

Redirect the user to session.url. On completion they return to your callback_url with the session outcome reflected in query params. This is a UX signal only — confirm the real decision server-side.

The flow itself runs whatever modules your workflow enables — document capture, liveness, face match, AML, and so on — and the server enforces the configured liveness method; it can't be downgraded from the client. See the module catalog for what each module does.

3. Receive the decision

Warning

The client-side result is a UX signal only. Grant access or move money only after your backend confirms the decision from the signed webhook. A user can close the tab after approval; the webhook is HMAC-signed and cannot be spoofed.

Terminal sessions emit a signed webhook:

{
  "event_type": "identity.kyc.session.approved",
  "session_id": "6f0e2b7a-3c1a-4e0d-9b2a-7f4c1d2e8a90",
  "vendor_data": "user-42",
  "decision": "approved"
}

The four terminal event types are identity.kyc.session.approved, .declined, .review, and .expired. Verify the X-Webhook-Signature (see Webhooks → Signatures) before you parse the body, then act on decision.

Prefer polling? Call GET /v3/session/<id>/decision/:

{
  "session_id": "6f0e2b7a-3c1a-4e0d-9b2a-7f4c1d2e8a90",
  "status": "approved",
  "decision": "approved",
  "decision_v3": {
    "id_verifications": [],
    "liveness_checks": [],
    "face_matches": [],
    "aml_screenings": [],
    "reviews": []
  },
  "reason": ""
}

Tip

Webhooks over polling: listening for identity.kyc.session.* events costs zero API reads, while a poll loop across many sessions burns your /v3 read rate-limit bucket. See Rate limits.

Edge cases worth handling now

  • Session never completed (expired). If the applicant never finishes before expires_at, the session moves to a terminal expired state and fires identity.kyc.session.expired. Design your UI to let the user request a fresh session rather than reusing a dead url.
  • Partial capture, then abandonment. A user who uploads a document but closes the tab before liveness/face-match leaves the session non-terminal until it expires — you will not get a webhook until then. Don't treat "no webhook yet" as a decline; check GET /v3/session/<id>/ if you need an earlier read.
  • Retried session creation. With a stable Idempotency-Key, a repeat POST /v3/session/ with the same key returns the original session (still 201) and is never charged twice — safe to call from an at-least-once job queue or a client retry.
  • Malformed request body. An unknown prefill key is silently ignored (not an error); a genuinely invalid field (e.g. malformed date_of_birth) or an unknown workflow_id returns 400 with { "detail": "..." }. An exhausted credit balance returns 402 with { "detail": "insufficient_credits" } — handle it as a hard stop, not a retry.

Next steps

Frequently asked questions