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/json

Note

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_idstringbody

KYC 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_datastringbody

Your 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_urlstringbody

Absolute 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_emailstringbody

Applicant email. Used for OTP and notifications where the workflow requires it.

contact_phonestringbody

Applicant phone. Used for OTP and notifications where the workflow requires it.

expires_in_hoursintegerbodyDefault: 48

Hosted-URL lifetime, 1..720 hours. After it lapses the applicant sees an expiry screen and the session settles as expired.

localestringbodyDefault: en

UI language for the hosted flow. See Customization.

prefillobjectbody

Applicant 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"
}
idstring

The session UUID. Use it on every follow-up: GET /v3/session/<id>/, .../decision/, .../logs/, etc.

session_kindstring

user for an applicant-facing hosted session.

statusstring

Raw session status. Starts at not_started.

urlstring

Absolute hosted-flow URL on your tenant's domain. Redirect the applicant here.

vendor_datastring

Echoes the vendor_data you sent.

decisionstring

Stable decision. in_progress until the session reaches a verdict.

expires_atstring

ISO-8601 timestamp when the hosted URL lapses.

created_atstring

ISO-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

StatusMeaning
201Session created (or returned unchanged for a repeated Idempotency-Key).
400Validation error — e.g. expires_in_hours out of 1..720, bad workflow_id.
401Missing or invalid API key.
402insufficient_credits.
429Rate-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.