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.
Console (operator) links
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.
API-created ThirdFactor Verify links
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.
Partner-signed JWT links
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:
| Claim | Required | Meaning |
|---|---|---|
jti | yes | Idempotency key and the application's external_ref. |
iat | yes | Issued-at (UNIX seconds). |
exp | yes | Expiry — ≤ 5 minutes after iat. |
flow_id | yes | The tenant flow to run. |
payload | no | Initial applicant data (phone, email, identifier, full_name). |
session_ttl_minutes | no | Hosted 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
}okbooleantrue on success. On failure this flips to false with an error code and
detail — see Errors.
session_idstringThe application id created behind the link. Use it with
GET /api/v1/applications/<id>/ and the
session-expiry PATCH.
urlstringThe hosted link, /s/<token> on your tenant's domain. Hand this to the
applicant.
expiresbooleanWhether the link expires at all. false when session_ttl_minutes: 0 was
requested.
expires_atstringISO-8601 expiry timestamp. Omitted/irrelevant when expires is false.
credits_remainingnumberTenant 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).
Link lifetime
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?
| Path | Best for | Link shape | Default expiry |
|---|---|---|---|
| Console link | Operators, 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
Related
Create a session
The native /v3 alternative when you have a server calling the API directly.
Authentication
Full JWT claim reference and how to sign the token.
Applications API
Read and manage the application behind a partner-JWT or console link.
Hosted flow overview
The applicant journey and full troubleshooting guide.