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.

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 locationOutcome
OffThe applicant continues; the session keeps the coarse, IP-based location.
OnContinue 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:

StepShown again as
Questionnaire, product selection, branch visit, confirm detailsEditable. 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 documentsRead-only — Verified document
Selfie / livenessRead-only — Liveness completed
Email or phone verified by OTPRead-only — Verified by OTP
SignatureRead-only — Signature captured
Information cardDisplay 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 actionWhat happens
Done (default)The flow ends as always — a redirect to callback_url, or the SDK closes.
RetryThe 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:

SessionField
The new attemptmetadata.retry_of (the declined session's id) and metadata.retry_attempt (1, 2, …)
The declined onemetadata.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:

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