Reference
Applications
Partner /api/v1 hosted sessions (JWT), application reads, session-expiry control, stable decisions.
The partner API (/api/v1) drives the hosted application flow: you sign a
short-lived JWT with your API key, exchange it for a hosted session URL, and
read the resulting application by ID. Applications carry a stable decision
contract for long-term branching, and — because a flow is a console-designed
sequence of steps (form fields, KYC, blocklist checks, review) — they support
richer flows than a bare KYC session.
Info
All endpoints on this page are on the /api/v1 surface. POST /api/v1/sessions/
authenticates with Authorization: Bearer <tenant_api_key> around a
partner-signed JWT body; GET/PATCH /api/v1/applications/<id>/ authenticate
with the raw key directly (Authorization: Bearer or X-API-Key).
Stable application decisions
Application workflow statuses are intentionally more detailed than the public
decision contract. Integrations should prefer application.decision where
available.
| Decision | Meaning |
|---|---|
approved | Application completed successfully. |
rejected | Application was rejected by identity/risk/operator logic. |
manual_review | Application requires operator review. |
in_progress | Applicant has not reached a final decision yet. |
abandoned | Application was closed/abandoned. |
Warning
Raw statuses such as form_filled, sdk_redirected, completed, and
kyc_failed may still appear for workflow diagnostics. Do not build long-term
external branching on raw statuses unless agreed in the integration contract —
only decision is guaranteed stable across flow redesigns.
Create a hosted session
POST /api/v1/sessions/
Authorization: Bearer <tenant_api_key>
Content-Type: application/jsonThe body is a single partner-signed HS256 JWT — not the raw key, and not the applicant fields directly.
jwtstringbodyrequiredAn HS256 JWT signed with your tenant API key (see claims below).
{ "jwt": "<HS256 partner-signed JWT>" }JWT claims
| Claim | Required | Meaning |
|---|---|---|
jti | yes | Idempotency key and external reference for the session. |
iat | yes | Issued-at UNIX timestamp. |
exp | yes | JWT expiry. Must be no more than 5 minutes after iat. |
flow_id | yes | Tenant flow to run. |
payload | no | Initial applicant data: phone, email, identifier, full_name. |
session_ttl_minutes | no | Hosted URL lifetime. Omit for tenant default, 0 for no expiry, or 1..525600 for a custom lifetime. |
# Node's jsonwebtoken CLI or any HS256 signer works here; shown as Python below
# since bash has no native JWT signer. The raw HTTP call:
curl -X POST https://acme.thirdfactor.ai/api/v1/sessions/ \
-H "Authorization: Bearer $OBSIDIAN_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: crypto.randomUUID(),
iat: now,
exp: now + 300, // <= 5 minutes after iat
flow_id: "savings_v1",
payload: {
phone: "9841000000",
email: "[email protected]",
full_name: "Aarav Sharma",
},
session_ttl_minutes: 30,
},
process.env.OBSIDIAN_API_KEY,
{ algorithm: "HS256" },
);
const resp = await fetch("https://acme.thirdfactor.ai/api/v1/sessions/", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OBSIDIAN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ jwt: token }),
});
const { url } = await resp.json();import time, uuid, jwt, requests
now = int(time.time())
token = jwt.encode(
{
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + 300, # <= 5 minutes after iat
"flow_id": "savings_v1",
"payload": {
"phone": "9841000000",
"email": "[email protected]",
"full_name": "Aarav Sharma",
},
"session_ttl_minutes": 30,
},
OBSIDIAN_API_KEY,
algorithm="HS256",
)
resp = requests.post(
"https://acme.thirdfactor.ai/api/v1/sessions/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"jwt": token},
)
url = resp.json()["url"]Response Example
{
"ok": true,
"session_id": "ge9vgmlmfbi9k5q",
"url": "https://acme.thirdfactor.ai/s/<token>",
"expires": true,
"expires_at": "2026-05-18T12:00:00Z",
"credits_remaining": 99
}okbooleantrue on success.session_idstringurlstringexpiresbooleanexpires_atstringexpires is true, else absent/null.credits_remainingintegerNote
Hosted session links use the tenant default configured in Console → Settings
→ API keys → Hosted session expiry. The default starts at 30 minutes, can be
changed by an operator, and can be disabled for newly-created hosted sessions.
Existing application URLs keep their stored expiry until explicitly updated. The
partner-signed JWT must be short-lived too: its exp - iat must be 5 minutes or
less. Operator-created console session links are separate and currently expire
after 24 hours.
Info
A hosted session whose flow contains an sdk step runs ThirdFactor Verify
inline as a single continuous flow for the applicant — no external verification
vendor and no hand-off out of the flow.
Idempotent creates
jti doubles as the idempotency key. A repeat POST with the same jti
re-mints a fresh hosted URL for the same underlying application and is not
charged again — the response includes "replay": true in that case so you can
distinguish it from a first-time create.
Errors
| Status | Code | When |
|---|---|---|
400 | invalid_request | Missing jwt in the body, or flow_id blank in the decoded claims. |
400 | invalid_request (session_ttl_minutes must be 0 or 1..525600) | session_ttl_minutes outside the valid range. |
401 | invalid_token | Authorization header missing/malformed, or the Bearer token doesn't match a tenant API key. |
401 | invalid_jwt (missing iat/exp, iat in future, expired, lifetime > 5min, missing jti) | JWT fails claim validation. |
402 | insufficient_credits | Tenant balance can't cover the SESSION_CREATE fee. |
404 | flow_not_found | flow_id doesn't resolve to a flow for this tenant. |
500 | internal | The credit debit failed for a reason other than an insufficient balance. |
Warning
A 401 invalid_jwt almost always means a clock or signing-secret problem, not
a malicious request. Double-check your server's clock is NTP-synced (the
iat/exp window is only 5 minutes wide) and that you're signing with the raw
API key, not a hashed or truncated copy of it.
Get an application
GET /api/v1/applications/<application_id>/Fetch the full record for a single application using your API key. The
response is scoped to your tenant — a 404 is returned for IDs that belong to
a different tenant or do not exist, indistinguishable from a truly unknown ID.
curl -s "https://acme.thirdfactor.ai/api/v1/applications/ge9vgmlmfbi9k5q/" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY"const resp = await fetch(
`https://acme.thirdfactor.ai/api/v1/applications/${applicationId}/`,
{ headers: { Authorization: `Bearer ${process.env.OBSIDIAN_API_KEY}` } },
);
const { application } = await resp.json();resp = requests.get(
f"https://acme.thirdfactor.ai/api/v1/applications/{application_id}/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
)
application = resp.json()["application"]Response Example
{
"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"
}
}idstringexternal_refstringjti used at creation.tracking_idstringstatusstringdecisionstringsession_expiry_statestringOne of active, expired, or never.
phonestringemailstringWarning
application.decision is advisory until confirmed server-side. Trust the signed
webhook or this GET read — never the browser redirect alone. See
Webhooks → Signatures.
Errors
| Status | Code | When |
|---|---|---|
401 | invalid API key | Missing/invalid Authorization/X-API-Key. |
404 | not_found | Unknown ID, or the ID belongs to another tenant. |
429 | rate-limited | Over the tenant's read bucket. |
Update session expiry
PATCH /api/v1/applications/<application_id>/Expire a live hosted URL immediately:
{ "expire_now": true }expire_nowbooleanbodySet to true to expire the hosted URL immediately. Takes precedence over
session_expires_at if both are present.
session_expires_atstringbodyISO 8601 timestamp, or null/"" to clear expiry entirely.
curl -X PATCH "https://acme.thirdfactor.ai/api/v1/applications/ge9vgmlmfbi9k5q/" \
-H "Authorization: Bearer $OBSIDIAN_API_KEY" -H "Content-Type: application/json" \
-d '{ "expire_now": true }'await fetch(`https://acme.thirdfactor.ai/api/v1/applications/${applicationId}/`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.OBSIDIAN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ expire_now: true }),
});requests.patch(
f"https://acme.thirdfactor.ai/api/v1/applications/{application_id}/",
headers={"Authorization": f"Bearer {OBSIDIAN_API_KEY}"},
json={"expire_now": True},
)Response is { "ok": true, "application": { ... } } — the same shape as
Get an application.
Errors
| Status | Code | When |
|---|---|---|
400 | invalid_session_expires_at | session_expires_at isn't a parseable timestamp. |
400 | no_supported_fields | Body has neither expire_now nor session_expires_at. |
401 | invalid API key | Missing/invalid Authorization/X-API-Key. |
404 | not_found | Unknown ID, or the ID belongs to another tenant. |
Webhooks
Application events are delivered with the stable signed envelope (payload_version,
event, application, form_data, sdk_response, blocklist_match,
created_at). New integrations should key on application.decision. See
Webhooks → Events and
Webhooks → Signatures.
Common pitfalls
Warning
Reusing one JWT for multiple applicants. Each POST /api/v1/sessions/ call
needs a fresh JWT with a unique jti and a <= 5 minute iat/exp window —
signing one JWT ahead of time and replaying it for many users will 401 after
the first few minutes, and reusing a jti returns the same application as a
replay, not a new one.
Warning
Branching on raw status instead of decision. form_filled,
sdk_redirected, and similar raw statuses are workflow-internal diagnostics
that change as flows are redesigned in the console. Only decision's five
values are a stable integration contract.
Warning
Forgetting session_ttl_minutes: 0 means "no expiry," not "expire
immediately." If you want an always-valid link for e.g. an in-branch kiosk
flow, set session_ttl_minutes to 0 explicitly — omitting it falls back to
the tenant's default expiry (30 minutes unless changed).