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.
Precise location
Workflow → Configurations → Ask for precise location makes the flow ask the
browser (or the SDK host) for a GPS fix. It is stored on the session as
precise_location — latitude, longitude, accuracy_m, a reverse-geocoded
address and captured_at — and reported next to the IP intelligence in the
session payload and webhooks. It is free: precise location never costs credits.
Nested under it, Require precise location to continue (off by default) decides what happens when the applicant denies the prompt, dismisses it, or the fix times out:
| Require precise location | Outcome |
|---|---|
| Off | The applicant continues; the session keeps the coarse, IP-based location. |
| On | Continue stays blocked on a location screen with a retry until a fix is captured. |
The requirement is enforced server-side as well: completing a session that has
no stored fix returns 409 precise_location_required, so a client that skips
the screen can't obtain a decision. Native apps must hold the OS location
permission — see Android,
iOS and Flutter.
Back navigation
Workflow → Configurations → Allow applicants to go back (on by default) puts a Back control at the top of the step card. The setting is pinned on each session when it is created, so changing it never affects a session already in progress. Off, the flow shows no Back, and the device back button (browser, Android) behaves exactly as it did before Back existed.
Back returns to the previous step the applicant actually visited — not simply the previous node of the workflow — without restarting the session. It passes over screens there is nothing to return to (welcome and Terms, the location and CAPTCHA gates, the video call). What a finished step shows when the applicant comes back to it:
| Step | Shown again as |
|---|---|
| Questionnaire, product selection, branch visit, confirm details | Editable. A change is accepted on Continue. Continue with nothing changed just moves on: nothing is reprocessed or charged. A change saves a new version of the step, and only the checks that read the changed data (a Condition, a lookup, AML, …) run again — a check that re-runs is never charged twice. |
| ID, address, custom and business documents | Read-only — Verified document |
| Selfie / liveness | Read-only — Liveness completed |
| Email or phone verified by OTP | Read-only — Verified by OTP |
| Signature | Read-only — Signature captured |
| Information card | Display only — Continue moves on |
Verified data can't be retaken or changed through Back. An input step whose data a partner already received (an API Action or form action, for example) or that was checked on the video call is read-only too, with that reason. Inside an editable questionnaire some fields stay locked with their reason — a declaration the applicant accepted (Consent accepted), an answer an OTP step verified (Verified by OTP), an uploaded file — and a field with field masking has to be typed again to save a change. If a change sends the applicant down a different workflow branch, the steps the old branch had finished are superseded (kept for audit) and the new branch's steps must be completed before the session can be submitted.
Back is hidden on the first step the applicant visited, once they press Start on a video call, and once the session is submitted. A refresh, or reopening the link, returns the applicant to the same step — including one they went back to.
Your integration sees it all additively: the session's allow_back,
back_count and edited, and every module's step_status, version and
edited on Get session and the
session webhooks. With Connect, each accepted
change fires step.reopened and re-sends the step's module.completed /
questionnaire.submitted with its new version.
Result screen & retries
The last screen shows the verdict — Verified (green), Failed (red) or Under review (orange); those colours are fixed. Its words come from the published workflow version: Workflow → Configurations → Result screen copy holds a Headline, Body copy and Button label per state. Like the Welcome copy, each field is written once, in the workflow's Default language; a blank field falls back to the built-in text in the applicant's language, which copy overrides can still change per locale. Branding is the workflow's own — there are no per-state themes.
The Failed button does one of two things:
| Button action | What happens |
|---|---|
| Done (default) | The flow ends as always — a redirect to callback_url, or the SDK closes. |
| Retry | The applicant starts a new session for the same workflow, opened in place. |
Public Self Start. While Public Self Start is on for your organization — the
platform's "Public self-start link" entitlement and your own "Public self
start" switch under Settings → Products — any state's button (Verified,
Failed or Under review) can instead be set to Public Self Start: tapping it
takes the applicant to your public product catalog ({public_base_url}/). The
option only appears in the builder while Public Self Start is on, and the flow
re-checks it when the result shows: if it has been switched off since, the
button is simply Done. A session created with its own callback_url keeps
it — the integration's return path wins, so the button behaves as Done and
returns there; the self-start link is the default for sessions without one.
Inside an embedding app or SDK the button always behaves as Done — the host
decides what comes next.
A retry never reopens the declined session — its evidence, decision, webhook
and billing stay as decided. The new attempt is an ordinary session: created on
the workflow's latest published version with the declined session's
vendor_data, contact details, callback_url, locale, branch and identity. It
fires its own webhooks, its modules bill like any other session's, and it
carries the same session fee a POST /v3/session/ does (SESSION_CREATE,
charged once per declined session however often the applicant taps). The two
sessions are linked both ways:
| Session | Field |
|---|---|
| The new attempt | metadata.retry_of (the declined session's id) and metadata.retry_attempt (1, 2, …) |
| The declined one | metadata.retry_session_id (the new attempt's id) |
Both timelines record the hand-over (session.retry_started on the declined
session, session.retry_of on the new one).
The flow offers Retry only when the server allows it; otherwise the button reads Done. It must be a session the applicant actually ran and that was declined (not one blocked at creation), the identity must not be blocked, the chain must be under the attempt limit (2 extra attempts), your balance must cover the session fee, and the session must not belong to an onboarding flow (which starts its own attempts).
Tip
Correlate by vendor_data, or follow metadata.retry_of: a retried
applicant's approval arrives on the new session's webhook, after the
declined session's. With the web SDK, set autoClose: false so the result
screen — and its Retry button — stays up; the SDK then resolves with the
latest attempt's status and sessionId.
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.