Documentation
Authentication
Tenant API keys, scopes, and the partner-signed JWT session flow.
Every request is authenticated with a tenant API key. Keys are tenant-scoped: one tenant can never read or mutate another tenant's data.
Obsidian exposes two authentication shapes over that one underlying key:
- Raw key, per-request — the ThirdFactor Verify API (
/v3) and the partner applications/lists API (/api/v1) both take the key directly, as a header. - Short-lived signed JWT — the partner hosted-session endpoint
(
POST /api/v1/sessions/) takes a JWT you sign yourself with a tenant-specific signing secret, not the raw key.
Which auth model do I use?
/v3 API key | /api/v1/sessions/ JWT | |
|---|---|---|
| What you send | The raw tenant API key, as x-api-key | A short-lived HS256 JWT you construct and sign |
| What signs it | Nothing — the key itself is the secret | Your tenant's dedicated JWT signing secret (separate from the API key) |
| Surface | ThirdFactor Verify — sessions, decisions, tools, workflows, usage, credits | Partner hosted-application sessions + risk lists |
| Where creation logic lives | You call POST /v3/session/ directly | You embed a flow_id + optional payload in the signed JWT, then POST it |
| Lifetime of the credential | Until revoked in the console | The JWT itself expires in ≤ 5 minutes; the signing secret is long-lived |
| Best for | Direct KYC integrations, custom onboarding backends, the SDKs | Partner integrations built around Obsidian's flow/application model |
| Detailed reference | API reference → Sessions | API reference → Applications |
Info
Both models are ultimately backed by the same tenant. If you only need
ThirdFactor Verify sessions (documents, liveness, face match, AML), you almost
certainly want the /v3 API key model — it's what the Quickstart
and every SDK use. The JWT model is specific to the partner application/flow
product.
API keys
Create keys in the console under Settings → API keys. The raw key value is
shown once, at creation time — the console only ever displays a
token_prefix afterward, so store it in your secrets manager immediately.
Send the key on the ThirdFactor Verify API (/v3) as an x-api-key header:
POST /v3/session/
x-api-key: <tenant_api_key>
Content-Type: application/jsonThe partner API (/api/v1, for applications and lists) accepts the same key
as a bearer token:
Authorization: Bearer <tenant_api_key>Warning
API keys are secret. Use them only from your server. Never embed a key in a web page, mobile app, or public repository — anyone with the key can create sessions and read your verifications.
Worked example: the /v3 API key model
There's no construction step — you send the raw key on every request and the
server does the work. On the server side, Obsidian never stores your raw key:
it SHA-256 hashes the value you send and looks up the matching
TenantAPIKey.key_hash. That's why a lost key can't be "recovered" from the
console — only revoked and replaced.
curl -X GET "$TF_BASE_URL/v3/session/" \
-H "x-api-key: $TF_API_KEY"const res = await fetch(`${process.env.TF_BASE_URL}/v3/session/`, {
headers: { "x-api-key": process.env.TF_API_KEY! },
});import requests
requests.get(f"{BASE_URL}/v3/session/", headers={"x-api-key": API_KEY})A missing or invalid key returns 401; a suspended tenant also fails closed
with 401 regardless of key validity — see Errors.
Partner-signed JWT (hosted sessions)
The partner endpoint POST /api/v1/sessions/ takes a short-lived HS256 JWT you
sign with your tenant's JWT signing secret (console: Settings → API
keys, shown as "JWT signing secret for <client_id>"), rather than the raw
API key in the body. This is used for the onboarding/application flow.
POST /api/v1/sessions/
Authorization: Bearer <tenant_api_key>
Content-Type: application/json
{ "jwt": "<HS256 partner-signed JWT>" }| Claim | Required | Meaning |
|---|---|---|
jti | yes | Idempotency key + external reference for the session. |
iat | yes | Issued-at (UNIX seconds). |
exp | yes | Expiry. Must be ≤ 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 tenant default, 0 for no expiry. |
Note
The ThirdFactor Verify API (/v3/session/) uses the API key directly — no JWT. The JWT
flow is specific to the partner /api/v1/sessions/ application endpoint.
Worked example: constructing the JWT
An HS256 JWT is just two base64url-encoded JSON objects and an HMAC-SHA256
signature, joined with .. The shell version below builds it the same way
generate_sdk_url.sh in the repo does — no library required, so you can debug
a signature mismatch by hand.
# Requires: TF_JWT_SIGNING_SECRET (console: Settings -> API keys),
# TF_FLOW_ID, TF_API_KEY, TF_BASE_URL
base64url() { base64 | tr '+/' '-_' | tr -d '='; }
NOW=$(date +%s)
EXP=$((NOW + 240)) # 4 minutes — stay under the 5-minute ceiling
HEADER='{"alg":"HS256","typ":"JWT"}'
HEADER_B64=$(echo -n "$HEADER" | base64url)
PAYLOAD=$(cat <<JSON
{"jti":"kyc-user-42-$NOW","iat":$NOW,"exp":$EXP,"flow_id":"$TF_FLOW_ID","payload":{"full_name":"Aarav Sharma","email":"[email protected]","phone":"9841000000"},"session_ttl_minutes":30}
JSON
)
PAYLOAD_B64=$(echo -n "$PAYLOAD" | base64url)
SIGNING_INPUT="${HEADER_B64}.${PAYLOAD_B64}"
SIGNATURE=$(echo -n "$SIGNING_INPUT" | openssl dgst -sha256 -hmac "$TF_JWT_SIGNING_SECRET" -binary | base64url)
JWT="${SIGNING_INPUT}.${SIGNATURE}"
curl -s -X POST "$TF_BASE_URL/api/v1/sessions/" \
-H "Authorization: Bearer $TF_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"jwt\": \"$JWT\"}"import crypto from "node:crypto";
function base64url(input: Buffer | string) {
return Buffer.from(input)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const now = Math.floor(Date.now() / 1000);
const header = { alg: "HS256", typ: "JWT" };
const payload = {
jti: `kyc-user-42-${now}`,
iat: now,
exp: now + 240, // <= 5 minutes after iat
flow_id: process.env.TF_FLOW_ID,
payload: { full_name: "Aarav Sharma", email: "[email protected]" },
session_ttl_minutes: 30,
};
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
const signature = base64url(
crypto.createHmac("sha256", process.env.TF_JWT_SIGNING_SECRET!).update(signingInput).digest()
);
const jwt = `${signingInput}.${signature}`;
const res = await fetch(`${process.env.TF_BASE_URL}/api/v1/sessions/`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TF_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ jwt }),
});
// { ok: true, session_id, url, expires, expires_at, credits_remaining }import base64, hashlib, hmac, json, os, time
import requests
def base64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"jti": f"kyc-user-42-{now}",
"iat": now,
"exp": now + 240, # <= 5 minutes after iat
"flow_id": os.environ["TF_FLOW_ID"],
"payload": {"full_name": "Aarav Sharma", "email": "[email protected]"},
"session_ttl_minutes": 30,
}
signing_input = ".".join([
base64url(json.dumps(header, separators=(",", ":")).encode()),
base64url(json.dumps(payload, separators=(",", ":")).encode()),
])
signature = base64url(
hmac.new(os.environ["TF_JWT_SIGNING_SECRET"].encode(), signing_input.encode(), hashlib.sha256).digest()
)
jwt = f"{signing_input}.{signature}"
resp = requests.post(
f"{os.environ['TF_BASE_URL']}/api/v1/sessions/",
headers={"Authorization": f"Bearer {os.environ['TF_API_KEY']}", "Content-Type": "application/json"},
json={"jwt": jwt},
timeout=15,
)
session = resp.json() # session["url"], session["session_id"]Response (200):
{
"ok": true,
"session_id": "ge9vgmlmfbi9k5q",
"url": "https://your-domain.example/s/<token>",
"expires": true,
"expires_at": "2026-07-12T12:30:00Z",
"credits_remaining": 99
}Tip
Most languages have a JWT library (jsonwebtoken on Node, PyJWT on Python)
that does the base64url + HMAC steps for you — use one in production code.
The manual construction above exists so you can eyeball each part when a
signature mismatch shows up, the same way generate_sdk_url.sh in the repo
lets you mint one from the shell for debugging.
Warning
Common JWT mistakes:
exp - iatover 5 minutes. The server rejects JWTs whose window exceeds five minutes, even ifexpitself is in the future. Computeexpfromiat, not from wall-clock time at request-send time, and keep clock skew in mind — sign right before sending.- Reusing
jtiacross different applicants.jtidoubles as the idempotency key and external reference. Reusing it for a different person returns the original session, not a new one — derive it from your own stable per-applicant identifier plus a timestamp or attempt counter. - Signing with the API key instead of the signing secret. The JWT is
signed with the tenant's dedicated JWT signing secret, not the API key you
send as
Authorization: Bearer. They are two different values shown on the same console page — mixing them up produces a signature the server can't verify (rejected beforeflow_idis even read). - Unknown or disabled
flow_id. The flow must exist and be active for the tenant; a stale ID from a deleted or renamed flow fails the same way as a missing one.
Key rotation
Two independent things can rotate, and mixing them up is the most common support question in this area:
From Settings → API keys you can either:
- Add a key — create a new labeled key alongside existing ones. Both
work simultaneously, so you can roll new services onto the new key
before revoking the old one. Check
last_used_aton the old key before revoking it, to confirm nothing is still using it. - Rotate all keys — a one-click hard cutover that revokes every
existing key for the tenant and issues a single new one. Use this after
a suspected leak, not for routine rollover, since every caller still
using an old key starts failing with
401immediately.
Revoking a key does not affect any other tenant, and does not touch in-flight sessions created before the revoke — those already exist and continue independent of the key that created them.
Warning
Top pitfall: rotating an API key thinking it also rotates the JWT signing secret (or vice versa). They are shown on the same console page but are different secrets protecting different things — an API-key rotation alone does not stop a leaked JWT signing secret from minting valid sessions.
Idempotency
Send an Idempotency-Key header on session creation. A repeat create with the
same key returns the original session and is never charged twice — safe for
retries and at-least-once job queues.
Idempotency-Key: <your-unique-key>On the JWT model, jti serves the same purpose — see the JWT mistakes above.
Errors
Branch on the HTTP status first, then on the stable string code.
| Status | Meaning |
|---|---|
400 | Validation error. |
401 | Missing / invalid API key. |
402 | insufficient_credits. |
404 | Not found. |
409 | Duplicate. |
429 | Rate-limited — back off and retry. |
See Resources → Errors for the full table and the per-surface body shapes.