Concepts
Create a session
POST /v3/session/ in depth — every field, the 201 response, Idempotency-Key, and the 402 insufficient-credits path.
A verification session is the unit of work: one applicant, one workflow, one
hosted URL. You create it from your server (never a browser or mobile app —
the API key is secret), then redirect the applicant to the returned url.
POST /v3/session/
x-api-key: <tenant_api_key>
Content-Type: application/jsonNote
This is the ThirdFactor Verify endpoint on the /v3 surface, authenticated with your
tenant API key directly — no JWT. The partner POST /api/v1/sessions/ endpoint
(JWT-based, returns a /s/<token> link) is a separate path documented in
No-code links and
Authentication.
Request
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]",
"contact_phone": "9841000000",
"expires_in_hours": 24,
"locale": "en",
"prefill": {
"full_name": "Aarav Sharma",
"date_of_birth": "1990-04-12",
"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({
workflow_id: "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
vendor_data: user.id,
callback_url: "https://app.example.com/kyc/done",
contact_email: user.email,
prefill: { full_name: user.fullName, nationality: "NP" },
}),
});
if (res.status === 402) throw new Error("insufficient_credits");
const session = await res.json(); // { id, url, status, ... }import requests
resp = requests.post(
f"{BASE_URL}/v3/session/",
headers={
"x-api-key": API_KEY,
"Idempotency-Key": f"kyc-{user_id}",
},
json={
"workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
"vendor_data": user_id,
"callback_url": "https://app.example.com/kyc/done",
"contact_email": email,
"prefill": {"full_name": full_name, "nationality": "NP"},
},
timeout=15,
)
if resp.status_code == 402:
raise RuntimeError("insufficient_credits")
session = resp.json() # session["url"], session["id"]Fields
Every field is optional — an empty body creates a session against the tenant's default workflow.
workflow_idstringbodyKYC workflow UUID (from GET /v3/workflows/).
When omitted, the tenant default workflow is used.
Tip
Pin workflow_id explicitly in production. Relying on "the tenant default"
means an operator changing a console setting silently changes what your
integration verifies.
vendor_datastringbodyYour external user reference. It is echoed back on the session and every
webhook, and doubles as the linked identity's external_user_id — use it to
correlate the result with your own record.
callback_urlstringbodyAbsolute URL the applicant is redirected to after completion. Treat the landing as a UX signal, not proof — confirm via webhook or the decision endpoint.
contact_emailstringbodyApplicant email. Used for OTP and notifications where the workflow requires it.
contact_phonestringbodyApplicant phone. Used for OTP and notifications where the workflow requires it.
expires_in_hoursintegerbodyDefault: 48Hosted-URL lifetime, 1..720 hours. After it lapses the applicant sees an
expiry screen and the session settles as expired.
localestringbodyDefault: enUI language for the hosted flow. See Customization.
prefillobjectbodyApplicant identity used to pre-populate the flow so the applicant types less. Only the keys below are read; unknown keys are ignored.
Every field, one payload
The example above only sets the fields a typical integration needs. Here is a
request that sets every documented field, including every prefill key, so
you can see the full shape in one place:
{
"workflow_id": "9c2f1c4e-9a2b-4f2f-8f0f-0f1f3f4f5f6f",
"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",
"first_name": "Aarav",
"last_name": "Sharma",
"display_name": "Aarav",
"date_of_birth": "1990-04-12",
"email": "[email protected]",
"phone": "9841000000",
"nationality": "NP",
"address_line1": "House 12, Baneshwor",
"address_line2": "Ward 10",
"city": "Kathmandu",
"state": "Bagmati",
"country": "NP",
"zip_code": "44600"
}
}Note
Any key in prefill outside this list is silently ignored, not rejected — a
typo like dob instead of date_of_birth won't error, it just won't
pre-populate anything. If a prefill value isn't showing up in the flow, check
the exact key spelling first.
Response
On success you get 201 Created:
{
"id": "6f0e2b7a-…",
"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-04T08:00:00Z",
"created_at": "2026-07-03T08:00:00Z"
}idstringThe session UUID. Use it on every follow-up: GET /v3/session/<id>/,
.../decision/, .../logs/, etc.
session_kindstringuser for an applicant-facing hosted session.
statusstringRaw session status. Starts at not_started.
urlstringAbsolute hosted-flow URL on your tenant's domain. Redirect the applicant here.
vendor_datastringEchoes the vendor_data you sent.
decisionstringStable decision. in_progress until the session reaches a verdict.
expires_atstringISO-8601 timestamp when the hosted URL lapses.
created_atstringISO-8601 creation timestamp.
Warning
decision on the create response is always in_progress — the applicant hasn't
started yet. Read the real outcome from the signed webhook or
GET /v3/session/<id>/decision/; never trust a client-reported result.
Idempotency
Session creation debits credits, so make retries safe with an Idempotency-Key
header. A repeat create with the same key returns the original session
(201) and is never charged twice — safe for at-least-once job queues and
network-timeout retries.
Idempotency-Key: <your-unique-key>Tip
Derive the key from something stable in your own system — e.g.
kyc-<your_user_id> or a per-onboarding UUID — so a blind retry of the same
logical operation reuses it.
Concurrent creates with the same key
If two requests carrying the same Idempotency-Key race each other (for
example, a double-click submitted twice, or a job retried while the first
attempt is still in flight), you should still end up with exactly one session
and exactly one debit — treat any 201 on a repeated key as "the session
exists," not "a new one was made." If you see a genuinely conflicting resource
error instead of a clean replay, re-read the existing session before creating
again rather than blindly retrying with a new key; see
Errors → 409 duplicate.
Warning
An Idempotency-Key only protects the create call. It does not make the
session itself safe to redirect an applicant to twice in parallel tabs — the
hosted flow is designed for one applicant working through it once. If your
product needs the applicant to be able to resume in a second tab, redirect them
to the same url rather than creating a second session.
Insufficient credits (402)
Creating a session debits the tenant's SESSION_CREATE credit rate. When the
balance is exhausted the endpoint returns 402 with a stable code:
{ "detail": "insufficient_credits" }Branch on the HTTP status first, then the string code — don't string-match
human-readable messages. Top up credits (or check the balance via
GET /v3/credits/ledger/) and retry with the same
Idempotency-Key.
Edge cases
Other status codes
| Status | Meaning |
|---|---|
201 | Session created (or returned unchanged for a repeated Idempotency-Key). |
400 | Validation error — e.g. expires_in_hours out of 1..720, bad workflow_id. |
401 | Missing or invalid API key. |
402 | insufficient_credits. |
429 | Rate-limited — back off and retry. |
See Resources → Errors for the full error contract and Resources → Rate limits.
Troubleshooting
Warning
Session created but url 404s. Redirect to the url string exactly as
returned — never reconstruct it from id. ThirdFactor Verify sessions return
/verify/<token>; the partner JWT and console-link surfaces return a
different shape, /s/<token> — don't assume one path prefix works for both.
See Hosted flow overview → Troubleshooting
for the full breakdown, including deleted-session and truncated-link causes.
Warning
Callback never fires. callback_url only fires as a browser redirect if
the applicant is still on the page when the session completes — it is not a
delivery guarantee. Build your business logic on the signed webhook (or a
GET /v3/session/<id>/decision/ poll), not on the redirect landing. See
Overview → The callback never fires
and Webhooks overview for delivery, retries, and the
console delivery log.
Related
Hosted flow overview
The applicant journey, server-enforced modules, and the full troubleshooting guide.
Customize the flow
Branding, theme, locale, and prefill.
Sessions API
Read, list, decision, logs, and status-override endpoints.
Webhooks
Receive the signed terminal decision.
Workflows
Control which modules run.
Errors
Status codes, stable error codes, and per-surface body shapes.