Concepts

No-code links

Ways to get an applicant into the hosted flow without your own backend calling the KYC API — console-generated links and the partner-signed JWT link flow.

Not every integration wants to call the API on every onboarding. ThirdFactor offers two link-generation paths that produce a hosted-flow URL you can email, text, or drop into a QR code — one for operators in the console, one for partners who prefer a signed-JWT handshake.

An operator can generate a session link straight from the console — no code, no API call. This is ideal for ad-hoc onboarding, branch-assisted verification, or sending a one-off link to a customer.

Info

Operator-created console session links are a separate lifecycle from API-created ones. They currently expire 24 hours after generation.

When your backend calls POST /v3/session/, the url in the 201 response (/verify/<session_token>) is the link — hand it to the applicant however you like. Its lifetime is expires_in_hours (1..720, default 48). This is the recommended path when you have a server.

The partner surface POST /api/v1/sessions/ mints a hosted link from a short-lived HS256 JWT you sign with your tenant API key, rather than sending the raw key in the request body. It runs a tenant flow (the application/flow model) and returns a /s/<token> link.

Request

POST /api/v1/sessions/
X-API-Key: <tenant_api_key>
Content-Type: application/json
{ "jwt": "<HS256 partner-signed JWT>" }

The JWT payload you sign before base64/HS256-encoding it looks like this — every claim in one place:

{
  "jti": "core-banking-onboarding-8f21a9",
  "iat": 1752307200,
  "exp": 1752307380,
  "flow_id": "savings_v1",
  "payload": {
    "phone": "9841000000",
    "email": "[email protected]",
    "identifier": "core-banking-id-123",
    "full_name": "Aarav Sharma"
  },
  "session_ttl_minutes": 60
}
curl -X POST "$TF_BASE_URL/api/v1/sessions/" \
  -H "X-API-Key: $TF_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"jwt\": \"$SIGNED_JWT\"}"
import jwt from "jsonwebtoken";

const now = Math.floor(Date.now() / 1000);
const token = jwt.sign(
  {
    jti: `onboarding-${user.id}`,
    iat: now,
    exp: now + 300, // must be <= 5 minutes after iat
    flow_id: "savings_v1",
    payload: { email: user.email, full_name: user.fullName },
    session_ttl_minutes: 60,
  },
  process.env.TF_API_KEY!,
  { algorithm: "HS256" },
);

const res = await fetch(`${process.env.TF_BASE_URL}/api/v1/sessions/`, {
  method: "POST",
  headers: {
    "X-API-Key": process.env.TF_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ jwt: token }),
});
const { url, session_id } = await res.json();
import time
import jwt
import requests

now = int(time.time())
token = jwt.encode(
    {
        "jti": f"onboarding-{user_id}",
        "iat": now,
        "exp": now + 300,  # must be <= 5 minutes after iat
        "flow_id": "savings_v1",
        "payload": {"email": email, "full_name": full_name},
        "session_ttl_minutes": 60,
    },
    API_KEY,
    algorithm="HS256",
)

resp = requests.post(
    f"{BASE_URL}/api/v1/sessions/",
    headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
    json={"jwt": token},
    timeout=15,
)
data = resp.json()  # data["url"], data["session_id"]

Full claim reference lives in Authentication:

ClaimRequiredMeaning
jtiyesIdempotency key and the application's external_ref.
iatyesIssued-at (UNIX seconds).
expyesExpiry — ≤ 5 minutes after iat.
flow_idyesThe tenant flow to run.
payloadnoInitial applicant data (phone, email, identifier, full_name).
session_ttl_minutesnoHosted URL lifetime. Omit for the tenant default, 0 for no expiry, 1..525600 for a custom lifetime.

Response

{
  "ok": true,
  "session_id": "ge9vgmlmfbi9k5q",
  "url": "https://acme.example.com/s/<token>",
  "expires": true,
  "expires_at": "2026-05-18T12:00:00Z",
  "credits_remaining": 99
}
okboolean

true on success. On failure this flips to false with an error code and detail — see Errors.

session_idstring

The application id created behind the link. Use it with GET /api/v1/applications/<id>/ and the session-expiry PATCH.

urlstring

The hosted link, /s/<token> on your tenant's domain. Hand this to the applicant.

expiresboolean

Whether the link expires at all. false when session_ttl_minutes: 0 was requested.

expires_atstring

ISO-8601 expiry timestamp. Omitted/irrelevant when expires is false.

credits_remainingnumber

Tenant credit balance after this call debited SESSION_CREATE.

Send the applicant to url. The jti you signed becomes the application's external_ref, so you can correlate the result with your own record without storing Obsidian's id.

Tip

jti is also the idempotency key. If the create call times out, retry with the same jti — you get the existing session back rather than a duplicate (and a second charge).

The hosted URL's default lifetime comes from the tenant setting in Console → Settings → API keys → Hosted session expiry (starts at 30 minutes; operators can change it or disable expiry for new sessions). Override per session with the session_ttl_minutes claim. Existing application URLs keep their stored expiry until you explicitly change it — for example, kill a live URL immediately:

Expire a live link immediately

curl -X PATCH "$BASE/api/v1/applications/$APP_ID/" \
  -H "X-API-Key: $TF_API_KEY" -H "Content-Type: application/json" \
  -d '{"expire_now": true}'

Set an explicit expiry

curl -X PATCH "$BASE/api/v1/applications/$APP_ID/" \
  -H "X-API-Key: $TF_API_KEY" -H "Content-Type: application/json" \
  -d '{"session_expires_at": "2026-05-18T12:00:00Z"}'

Make a link non-expiring

curl -X PATCH "$BASE/api/v1/applications/$APP_ID/" \
  -H "X-API-Key: $TF_API_KEY" -H "Content-Type: application/json" \
  -d '{"session_expires_at": null}'

All three return the updated application. You can read the current expiry state at any time — session_expiry_state on GET /api/v1/applications/<id>/ is one of active, expired, or never:

{
  "ok": true,
  "application": {
    "id": "ge9vgmlmfbi9k5q",
    "external_ref": "core-banking-id-123",
    "tracking_id": "APP-2026-0001",
    "status": "manual_review",
    "decision": "manual_review",
    "session_expires_at": "2026-05-18T12:00:00Z",
    "session_expiry_state": "active",
    "phone": "9841000000",
    "email": "[email protected]",
    "created_at": "2026-05-18T08:00:00Z",
    "updated_at": "2026-05-18T08:10:00Z"
  }
}

Which one should I use?

PathBest forLink shapeDefault expiry
Console linkOperators, ad-hoc / assisted onboarding/s/<token>24 h
ThirdFactor Verify POST /v3/session/Backend-driven KYC onboarding/verify/<token>48 h (1..720)
Partner JWT POST /api/v1/sessions/Backend that prefers a signed handshake over the raw key; application/flow model/s/<token>tenant default (from 30 min)

Note

Whichever link the applicant opens, the verification runs the same server-driven hosted flow. The client cannot weaken a module, and the result on screen is advisory — confirm the outcome from the signed webhook or a decision read on your backend.

Troubleshooting

Warning

Link 404s. By far the most common cause is opening a /verify/<token> link against the wrong path, or vice versa. ThirdFactor Verify POST /v3/session/ sessions live at /verify/<token>; console links and partner-JWT sessions live at /s/<token>. If you generate a link with one surface and hardcode the other prefix anywhere downstream (a redirect rule, a QR-code template, a link shortener), it will 404 even though the session is perfectly valid. Always use the exact url returned by the surface you called — don't infer or hand-build the path.

Warning

Callback / redirect never fires for a console or JWT link. Console-generated links don't carry a callback_url the way POST /v3/session/ does — there's no per-session redirect target to configure from the console UI, so "nothing happens" on completion is expected for that path. For partner-JWT links, the post-completion redirect (if any) is a property of the tenant flow (flow_id), not a claim you set on the JWT. In both cases, the reliable channel is the signed identity.application.* / identity.kyc.session.* webhook — see Webhooks overview — not a browser redirect.

Warning

JWT create call fails with an auth or validation error. The two most common causes: (1) exp - iat is more than 5 minutes, which the server rejects outright — keep the JWT short-lived and mint it right before the request, not ahead of time; (2) the JWT was signed with the wrong secret — it must be signed with the same tenant API key you send as X-API-Key, not a different key or a generic JWT secret.

FAQ