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.

DecisionMeaning
approvedApplication completed successfully.
rejectedApplication was rejected by identity/risk/operator logic.
manual_reviewApplication requires operator review.
in_progressApplicant has not reached a final decision yet.
abandonedApplication 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/json

The body is a single partner-signed HS256 JWT — not the raw key, and not the applicant fields directly.

jwtstringbodyrequired

An HS256 JWT signed with your tenant API key (see claims below).

{ "jwt": "<HS256 partner-signed JWT>" }

JWT claims

ClaimRequiredMeaning
jtiyesIdempotency key and external reference for the session.
iatyesIssued-at UNIX timestamp.
expyesJWT expiry. Must be no more than 5 minutes after iat.
flow_idyesTenant flow to run.
payloadnoInitial applicant data: phone, email, identifier, full_name.
session_ttl_minutesnoHosted 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
}
okboolean
Always true on success.
session_idstring
The application/session identifier.
urlstring
Hosted application URL to redirect the applicant to.
expiresboolean
Whether the hosted URL has an expiry.
expires_atstring
Expiry timestamp when expires is true, else absent/null.
credits_remaininginteger
Tenant credit balance after the debit.

Note

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

StatusCodeWhen
400invalid_requestMissing jwt in the body, or flow_id blank in the decoded claims.
400invalid_request (session_ttl_minutes must be 0 or 1..525600)session_ttl_minutes outside the valid range.
401invalid_tokenAuthorization header missing/malformed, or the Bearer token doesn't match a tenant API key.
401invalid_jwt (missing iat/exp, iat in future, expired, lifetime > 5min, missing jti)JWT fails claim validation.
402insufficient_creditsTenant balance can't cover the SESSION_CREATE fee.
404flow_not_foundflow_id doesn't resolve to a flow for this tenant.
500internalThe 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"
  }
}
idstring
The application/session identifier.
external_refstring
Your reference — the JWT jti used at creation.
tracking_idstring
Human-facing application tracking ID, shown in the console.
statusstring
Raw workflow status — diagnostic only, see the warning above.
decisionstring
Stable decision — branch on this. See the table above.
session_expiry_statestring

One of active, expired, or never.

phonestring
Applicant phone, when collected.
emailstring
Applicant email, when collected.

Warning

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

StatusCodeWhen
401invalid API keyMissing/invalid Authorization/X-API-Key.
404not_foundUnknown ID, or the ID belongs to another tenant.
429rate-limitedOver the tenant's read bucket.

Update session expiry

PATCH /api/v1/applications/<application_id>/

Expire a live hosted URL immediately:

{ "expire_now": true }
expire_nowbooleanbody

Set to true to expire the hosted URL immediately. Takes precedence over session_expires_at if both are present.

session_expires_atstringbody

ISO 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

StatusCodeWhen
400invalid_session_expires_atsession_expires_at isn't a parseable timestamp.
400no_supported_fieldsBody has neither expire_now nor session_expires_at.
401invalid API keyMissing/invalid Authorization/X-API-Key.
404not_foundUnknown 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).

FAQ