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"
}| Field | Meaning |
|---|---|
id | The session UUID — use it for GET, update-status, tags, logs, generate-pdf. |
url | Redirect the applicant here, or hand it to a client SDK. Absolute URL on your tenant's flow domain. |
status | Raw session status. Starts not_started; branch on decision/webhooks for external logic, not this. |
decision | in_progress until terminal. Becomes approved / declined / manual_review / expired. |
expires_at | Hosted-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:
- Wrong key type.
/v3/session/wantsx-api-key: <key>(a raw header value). It is not a bearer token — sendingAuthorization: Bearer <key>here fails with401. That format is for the separate/api/v1partner surface. See Authentication for which surface wants which. - 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. callback_urlmismatches. The applicant is redirected back to exactly thecallback_urlyou 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 terminalexpiredstate and firesidentity.kyc.session.expired. Design your UI to let the user request a fresh session rather than reusing a deadurl. - 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 repeatPOST /v3/session/with the same key returns the original session (still201) and is never charged twice — safe to call from an at-least-once job queue or a client retry. - Malformed request body. An unknown
prefillkey is silently ignored (not an error); a genuinely invalid field (e.g. malformeddate_of_birth) or an unknownworkflow_idreturns400with{ "detail": "..." }. An exhausted credit balance returns402with{ "detail": "insufficient_credits" }— handle it as a hard stop, not a retry.
Next steps
Authentication
API keys, scopes, and the JWT session flow.
Workflows
Configure which modules run and in what order.
SDKs
Embed verification in web and mobile apps.
Go live
The production readiness checklist.