Reference
API reference
Two REST surfaces, one tenant API key — ThirdFactor Verify (/v3) and partner (/api/v1).
Obsidian exposes two REST surfaces behind one tenant API key: the ThirdFactor
Verify API (/v3) that drives the built-in verification engine, and the
partner API (/api/v1) for hosted application sessions and risk lists. Both are
tenant-scoped — one tenant can never read or mutate another tenant's data,
regardless of which surface, endpoint, or credential is used.
Pick the surface by what you're building, not by habit — the two overlap on "start a hosted verification" but answer different integration shapes:
ThirdFactor Verify (/v3)
You want a single verification session — document, liveness, face match,
AML — with a compact decision and per-module results. Start here for a
standalone KYC check bolted onto an existing product.
Partner (/api/v1)
You want a multi-step application (form fill → KYC → review →
approval) driven by a flow you designed in the console, with a
partner-signed JWT for auth and a stable long-term decision contract.
Base URLs
| Surface | Base URL | Use it for |
|---|---|---|
| ThirdFactor Verify | https://<your-domain>/v3 — no /api/v1 prefix | Verification sessions, decisions, standalone tools, usage. |
| Partner | https://<your-domain>/api/v1 | Hosted application sessions (JWT), applications, risk lists. |
<your-domain> is your tenant's flow domain, e.g.
acme.demo-obsidian.thirdfactor.ai.
Authentication
Both surfaces authenticate with the same tenant API key, created in Console → Settings → API keys.
POST /v3/session/
x-api-key: <tenant_api_key>
Content-Type: application/jsonPOST /api/v1/sessions/
Authorization: Bearer <tenant_api_key>
Content-Type: application/jsonThe /v3 surface accepts the key as either X-API-Key or x-api-key. The
/api/v1/sessions/ create call is special: the Bearer token is your API key,
but the request body is a short-lived JWT you sign with that same key —
see Applications → JWT claims.
Every other /api/v1 endpoint (applications, lists) takes the raw key
directly, as either Authorization: Bearer or X-API-Key.
See Authentication for the full key + JWT model, including key rotation and scoping.
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, read your verifications, and mutate your risk lists.
The two surfaces
Sessions (/v3)
ThirdFactor Verify sessions: create, read, decision, tags, logs, PDF.
Applications (/api/v1)
Partner-signed JWT hosted sessions and stable application decisions.
Risk lists (/api/v1)
Tenant-scoped phone / email / identifier block lists used by flows.
Tools (/v3)
One-shot AML, face, signature and document utilities.
Workflows (/v3)
List the KYC workflows a session can be routed to.
Usage & credits (/v3)
Per-workflow spend and the raw credit ledger.
API design philosophy
A few conventions hold across both surfaces. Understanding them up front saves a round trip to support when a retry, a stale page, or an unfamiliar status code shows up in production.
Idempotency
Session creation is the one call integrators retry most (network blips, load balancer timeouts, at-least-once job queues), so both create endpoints are idempotent on a caller-supplied key:
| Surface | Idempotency key | Behavior on replay |
|---|---|---|
POST /v3/session/ | Idempotency-Key header (any string, ≤128 chars) | Returns the original session, 201, not charged again. |
POST /api/v1/sessions/ | JWT jti claim | Returns the original application's session (a freshly minted hosted URL, since the raw token isn't recoverable), credits_remaining unchanged. |
Tip
Always send an idempotency key on session creation. Without one, a dropped response you retry blindly creates a second session and a second debit — with one, retries are free and safe. Generate it once per logical attempt (e.g. a UUID tied to the user's checkout attempt), not per HTTP call.
Every other write (PATCH /v3/session/<id>/update-status/, list entry
upserts, tag add/remove) is naturally idempotent or safely re-runnable —
re-sending the same body produces the same end state rather than a duplicate.
Pagination
/v3 list endpoints use page / page_size query parameters uniformly, but
the response shape depends on when the endpoint was added:
| Endpoint | Body | Totals |
|---|---|---|
GET /v3/session/ | bare JSON array | X-Total-Count / X-Page / X-Page-Size response headers |
GET /v3/credits/ledger/ | { count, page, page_size, results } | inline in the body |
GET /api/v1/lists/<id>/entries/ | { ok, total, page, page_size, entries } | inline in the body |
Newer endpoints put paging metadata in the body because it's easier to consume
from a browser fetch; GET /v3/session/ predates that convention and keeps a
bare array for backward compatibility — read its totals from headers, not the
body. Always read page_size from the actual response, not just what you
requested — every endpoint clamps it to a maximum (200 for sessions, 500
for the ledger and list entries).
Versioning stance
There is no v2/v3 per-request negotiation — /v3 is the current and only
version of the ThirdFactor Verify surface (the /v3 name is a legacy artifact,
not a live version number you can pin to), and /api/v1 is the current and only version
of the partner surface. Both are additive: new fields appear on existing
responses without warning, so integrations should read named keys and ignore
unknown ones rather than asserting an exact response shape. Breaking changes
(field removal, semantic changes to an existing field) ship as a new endpoint
or a new field, with the old one kept working — announced in
Resources → Changelog.
Decisions are advisory until confirmed server-side
Any decision or status your browser or mobile SDK observes — a redirect
query param, an SDK completion callback, a client-rendered "Approved!" screen
— is advisory. The end user's device is not a trusted source of truth.
Always confirm the real outcome one of two ways before granting access or
provisioning an account:
- The signed webhook (
identity.kyc.session.*/identity.application.*) — push, arrives once the decision is final. See Webhooks → Signatures. - A server-side
GET /v3/session/<id>/decision/orGET /api/v1/applications/<id>/read — pull, safe to call any time.
Tenant scoping
Every queryset on every endpoint is filtered by the tenant resolved from the
API key that authenticated the request — never from an ID in the URL or body.
Passing another tenant's session or application ID returns 404, identical to
an ID that doesn't exist at all; the API never reveals that a resource
exists in another tenant, only that it doesn't exist in yours.
Error format
Errors carry the appropriate HTTP status. The JSON body shape currently depends on the surface:
| Surface | Success | Error |
|---|---|---|
/v3/* (KYC) | bare object / array | { "detail": "code_or_message" } |
/api/v1/sessions/, /api/v1/applications/ | { "ok": true, … } | { "ok": false, "error": "code", "detail": "…" } |
/api/v1/lists/* | { "ok": true, … } | { "error": "message" } |
| Status | Meaning |
|---|---|
400 | Validation error — malformed body, unknown enum value, bad date format. |
401 | Missing / invalid API key, or an invalid/expired JWT on /api/v1/sessions/. |
402 | insufficient_credits — the tenant's balance can't cover the debit. |
403 | feature_not_enabled — the tenant isn't entitled to a gated tool/module. |
404 | Not found — including cross-tenant IDs, indistinguishable from truly missing. |
409 | Duplicate — e.g. a list name that already exists, or a race on a unique entry value. |
415 | Unsupported image type on a /v3/tools/* upload (only JPEG/PNG are accepted). |
422 | The input was well-formed but semantically unusable (no face detected, no signature ink found). |
429 | Rate-limited — back off and retry. |
500 / 502 / 503 | Internal error, upstream dependency unavailable, or a gated engine not configured for the tenant. |
Branch on the HTTP status first, then treat stable string codes (e.g.
insufficient_credits, not_found, invalid_status) as the machine signal. A
unified { "error": { "code", "message" } } envelope across all surfaces is
planned — until then, do not string-match human-readable messages. See
Resources → Errors for the full code catalog.
Rate limits
/v3 endpoints are rate-limited per tenant, with separate buckets for reads,
writes, session creation, and inference-heavy tools. Over-limit requests get
HTTP 429; back off and retry with jitter rather than hammering the endpoint.
See Resources → Rate limits for the current
bucket sizes.