Concepts
Hosted flow overview
The applicant-facing verification page ThirdFactor hosts for you at /verify/<token> — what it does, when to use it, and why the server drives it.
The hosted flow is the verification experience ThirdFactor renders for your
applicant. You create a session on your server, redirect the user to the
returned url, and ThirdFactor handles document capture, liveness, selfie,
AML and every other enabled module — including the retake guidance that gets a
usable image the first time. You never build capture UI.
Every verification runs through the same hosted flow, whichever way you start it:
API-created
You call POST /v3/session/ and redirect to the returned url.
SDK-embedded
A web or mobile SDK opens the same page in embed mode.
No-code link
An operator generates a link in the console, or a partner mints one from a signed JWT.
When to use the hosted flow vs. the SDKs
- You want the fastest integration: one server call, one redirect, one webhook.
- Web onboarding, an emailed/SMS link, or a QR code the applicant scans.
- You don't need the verification to live inside your own app's chrome.
- You don't need NFC passport chip reading (that requires a mobile SDK).
Both delivery mechanisms drive the identical server-side engine, so the choice comes down to how completion is reported and whether you need NFC:
| Capability | Hosted flow (redirect) | SDK (embedded) |
|---|---|---|
| Integration effort | ✅ one API call + one redirect | ⚠️ install and initialize an SDK |
| Runs inside your app's own UI chrome | ❌ full-page takeover | ✅ embedded view / modal |
| Completion signal | Browser redirect to callback_url | Lifecycle events (ready, completed, error, close) |
| NFC passport / eID chip read | ❌ not available | ✅ iOS, Android, Flutter only |
| Branding (logo, colour, theme) | ✅ tenant/workflow-level | ✅ same, tenant/workflow-level |
| Server-side module enforcement | ✅ | ✅ |
| Shareable via email / SMS / QR | ✅ | ❌ requires your app to be installed |
Info
The hosted flow and the SDKs render identical capture UI and run the identical server-side engine. Choosing one over the other is a delivery decision, not a capability trade-off — except NFC, which is mobile-SDK only.
The applicant journey
Land on the flow
The applicant opens url (/verify/<session_token> on your tenant's flow
domain). The page resolves the token, loads the workflow, and paints your
branding — logo, colour, copy, light or dark theme.
Consent & intro
An intro/consent screen is shown, plus versioned Terms & Conditions if the tenant has enabled them.
Run the modules
The workflow's enabled modules run in order — document capture, liveness, selfie/face match, AML, proof of address, and so on. Real-time capture intelligence (framing, glare, blur, lighting) prompts a retake before a submission fails.
Reach a verdict
The engine settles the session to a decision, or holds it for operator review.
Return
The applicant is redirected to the session's callback_url. In embed mode
(SDKs), the outcome is reported to the host over the bridge instead of a
redirect.
Warning
The screen the applicant lands on — and any result the SDK surfaces — is a UX
signal only. Grant access or move money only after your backend confirms the
outcome from the signed webhook or a fresh GET /v3/session/<id>/decision/.
The applicant can close the tab after approval; the webhook is HMAC-signed and
cannot be spoofed.
End-to-end example
The three pieces below — create, redirect, webhook — are the whole integration. Field-by-field detail for the request lives in Create a session; the full webhook contract lives in Webhooks → Events.
Your server creates 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,
"locale": "en"
}'201 Created:
{
"id": "6f0e2b7a-1c2d-4e3f-9a1b-2c3d4e5f6a7b",
"session_kind": "user",
"status": "not_started",
"url": "https://acme.example.com/verify/eyJhbGciOiJIUzI1NiJ9.abc123",
"vendor_data": "user-42",
"decision": "in_progress",
"expires_at": "2026-07-13T08:00:00Z",
"created_at": "2026-07-12T08:00:00Z"
}You redirect the applicant to url
Send the applicant's browser to the exact url string — do not reconstruct it
from id. They complete the modules the workflow requires, then land back on
callback_url (a UX signal, not proof).
Your webhook endpoint receives the signed outcome
{
"session_id": "6f0e2b7a-1c2d-4e3f-9a1b-2c3d4e5f6a7b",
"status": "approved",
"decision": "approved",
"decision_reason": "",
"vendor_data": "user-42",
"workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
"application_id": null,
"created_at": "2026-07-12T08:00:00Z",
"completed_at": "2026-07-12T08:06:12Z",
"event_type": "identity.kyc.session.approved",
"idempotency_key": "b2a1c3d4-…"
}Verify X-Webhook-Signature, then branch on decision and correlate the user
via vendor_data. This — or a fresh
GET /v3/session/<id>/decision/ — is the only
outcome you should act on.
Server-enforced modules
The applicant's client never chooses or weakens a step. The workflow — built in the console under Config → KYC Workflows — decides which modules run and how strict each one is, and the server enforces that:
- The configured liveness method (passive, colour-flash, or 3D-action) cannot be downgraded client-side.
- The applicant cannot skip a required module, reorder the flow, or mark their own result as passed. Applicant-facing results are deliberately shallow (pass/fail per step); scores, warnings and engine detail stay server-side and are only visible to operators in the console.
- Evidence (documents, selfies, liveness stills) is sensitive PII. It is only readable through the authenticated, tenant-ownership-checked console view — it is never served from the public media namespace.
Which workflow a session runs is resolved at create time — pin it explicitly
with workflow_id. See Create a session and
Concepts → Workflows.
Session lifecycle & expiry
A hosted URL is single-purpose and time-boxed. Its lifetime is set at create
time (expires_in_hours, 1..720, default 48 for ThirdFactor Verify (/v3) sessions).
Once it expires the applicant sees an expiry screen, the session settles as
expired, and identity.kyc.session.expired fires.
Terminal sessions emit the standard signed webhook:
| Event | When |
|---|---|
identity.kyc.session.approved | Verification passed. |
identity.kyc.session.declined | Verification failed. |
identity.kyc.session.review | Held for operator review. |
identity.kyc.session.expired | The hosted URL lapsed before completion. |
See Webhooks → Events and Concepts → Sessions for the full status/decision mapping.
Troubleshooting
Warning
in_review is not a bug and not a dead end — it is a real, sometimes long-lived
state. A session can sit in review for hours and still settle to approved or
declined later. Don't treat "no terminal event yet" as a failure.
The session URL 404s
This is almost always a mismatch between the URL you're opening and the one ThirdFactor returned:
- You rebuilt the URL instead of using the
urlfield verbatim. The token is opaque; don't concatenateidonto a path yourself. Always redirect to the exact string returned by the create call. - You mixed up link shapes. ThirdFactor Verify
POST /v3/session/returns/verify/<token>; the partnerPOST /api/v1/sessions/and console-generated links return/s/<token>. Opening a/s/token against/verify/(or vice versa) 404s. See No-code links for which path each surface produces. - The session was deleted.
DELETE /v3/session/<id>/delete/soft-deletes the session and its evidence; a URL for a deleted session is gone. - The link was truncated in transit. Long tokens can get clipped by SMS
templates, email link-shortening, or a wrapping line in a copy/paste. Log the
full
urlyour server received and diff it against what was actually sent.
The applicant is stuck on a step
Pull the session's server-side view first — it usually tells you exactly where it stalled:
curl -H "x-api-key: $TF_API_KEY" "$TF_BASE_URL/v3/session/<id>/logs/"
curl -H "x-api-key: $TF_API_KEY" "$TF_BASE_URL/v3/session/<id>/checklist/"Common causes:
- NFC step opened outside a mobile SDK. If the workflow requires NFC chip reading, the applicant can't complete it in a plain browser hosted flow — NFC only runs inside the iOS, Android, and Flutter SDKs. Route these applicants through a mobile SDK, not the redirect link.
- OTP required but no contact info was supplied. If the workflow uses
email or SMS OTP and you didn't pass
contact_email/contact_phoneon create, the applicant has to enter contact details themselves mid-flow — slower, and easy to abandon. Pass them at create time whenever you already have them. - Terms & Conditions blocking silently. If T&C acceptance is required and
the tenant hasn't set copy for the session's
locale, the accept screen may render with fallback text an applicant doesn't trust enough to continue. Check Settings → Terms and Settings → Translations for that locale.
The callback never fires
"Callback" usually means one of two different things — make sure you're debugging the right one:
Warning
callback_url is a browser redirect, not a delivery guarantee. It only
fires if the applicant's device is still on the page when the flow finishes. If
they close the tab, background the app, or lose connectivity right after
completing, the redirect never happens — by design, this is a UX convenience,
not the integration's source of truth.
The webhook is the reliable channel. If it truly never arrives:
- Confirm an endpoint URL and signing secret are set under Console → Developers → Webhooks — an unconfigured endpoint has nothing to deliver to.
- Confirm your handler returns
2xxquickly. Delivery is at-least-once with backoff; a timeout, a4xx/5xx, or a slow handler is treated as failed and retried, not dropped — but it also means "never fires" can actually mean "kept failing silently on your side." - Check the delivery log on that same console screen — it lists every attempt with its response code and lets you replay one on demand.
- Confirm the session actually reached a terminal state. A session sitting in
in_reviewhasn't fired a terminal event yet; that's expected, not broken.
FAQ
Related
Create a session
POST /v3/session/ — every field, the 201 response, idempotency, credits.
Customize the flow
Branding, theme, locale, and prefill.
No-code links
Console links and the partner JWT link flow.
SDKs overview
Embed the same flow in web and mobile apps.
Webhooks overview
The signed, at-least-once delivery channel that is your source of truth.
Sessions & applications
Full status table, session_kind, vendor_data, and expiry semantics.