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

SurfaceBase URLUse it for
ThirdFactor Verifyhttps://<your-domain>/v3no /api/v1 prefixVerification sessions, decisions, standalone tools, usage.
Partnerhttps://<your-domain>/api/v1Hosted 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/json
POST /api/v1/sessions/
Authorization: Bearer <tenant_api_key>
Content-Type: application/json

The /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

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:

SurfaceIdempotency keyBehavior 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 claimReturns 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:

EndpointBodyTotals
GET /v3/session/bare JSON arrayX-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:

  1. The signed webhook (identity.kyc.session.* / identity.application.*) — push, arrives once the decision is final. See Webhooks → Signatures.
  2. A server-side GET /v3/session/<id>/decision/ or GET /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:

SurfaceSuccessError
/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" }
StatusMeaning
400Validation error — malformed body, unknown enum value, bad date format.
401Missing / invalid API key, or an invalid/expired JWT on /api/v1/sessions/.
402insufficient_credits — the tenant's balance can't cover the debit.
403feature_not_enabled — the tenant isn't entitled to a gated tool/module.
404Not found — including cross-tenant IDs, indistinguishable from truly missing.
409Duplicate — e.g. a list name that already exists, or a race on a unique entry value.
415Unsupported image type on a /v3/tools/* upload (only JPEG/PNG are accepted).
422The input was well-formed but semantically unusable (no face detected, no signature ink found).
429Rate-limited — back off and retry.
500 / 502 / 503Internal 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.