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:

CapabilityHosted 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 signalBrowser redirect to callback_urlLifecycle 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:

EventWhen
identity.kyc.session.approvedVerification passed.
identity.kyc.session.declinedVerification failed.
identity.kyc.session.reviewHeld for operator review.
identity.kyc.session.expiredThe 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 url field verbatim. The token is opaque; don't concatenate id onto 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 partner POST /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 url your 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_phone on 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 2xx quickly. Delivery is at-least-once with backoff; a timeout, a 4xx/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_review hasn't fired a terminal event yet; that's expected, not broken.

FAQ