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 sendThe raw tenant API key, as x-api-keyA short-lived HS256 JWT you construct and sign
What signs itNothing — the key itself is the secretYour tenant's dedicated JWT signing secret (separate from the API key)
SurfaceThirdFactor Verify — sessions, decisions, tools, workflows, usage, creditsPartner hosted-application sessions + risk lists
Where creation logic livesYou call POST /v3/session/ directlyYou embed a flow_id + optional payload in the signed JWT, then POST it
Lifetime of the credentialUntil revoked in the consoleThe JWT itself expires in ≤ 5 minutes; the signing secret is long-lived
Best forDirect KYC integrations, custom onboarding backends, the SDKsPartner integrations built around Obsidian's flow/application model
Detailed referenceAPI reference → SessionsAPI 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/json

The 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>" }
ClaimRequiredMeaning
jtiyesIdempotency key + external reference for the session.
iatyesIssued-at (UNIX seconds).
expyesExpiry. Must be ≤ 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 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 - iat over 5 minutes. The server rejects JWTs whose window exceeds five minutes, even if exp itself is in the future. Compute exp from iat, not from wall-clock time at request-send time, and keep clock skew in mind — sign right before sending.
  • Reusing jti across different applicants. jti doubles 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 before flow_id is 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_at on 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 401 immediately.

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.

StatusMeaning
400Validation error.
401Missing / invalid API key.
402insufficient_credits.
404Not found.
409Duplicate.
429Rate-limited — back off and retry.

See Resources → Errors for the full table and the per-surface body shapes.

Frequently asked questions