Reference

Go-live checklist

Keys, webhooks, idempotency, credits, error handling, and module choices before production traffic.

Work through this before real users hit your integration. Each item maps to a failure mode we've seen in production. Where a check needs a worked example, it's linked to one below.

Keys and secrets

API keys live only in server-side config — never in a browser bundle, a mobile binary, or a committed .env.
The webhook signing secret is stored server-side and has a rotation owner and runbook.
Session URLs are not logged and are not sent over channels you don't control.
You use a separate, non-production tenant (and its own keys) for staging and tests.

The /v3 API takes the key as x-api-key; the partner /api/v1 API accepts the same key as Authorization: Bearer <key>. Keys are tenant-scoped — one tenant can never read another's data.

Sandbox vs. production tenant

Sandbox / staging tenantProduction tenant
API keysSeparate keys, safe to share with CIRestricted to prod secrets manager
CreditsTest balance, safe to drainReal balance — monitor with alerts
Evidence (documents, selfies)Real PII from test runs — still needs deletion hygieneReal applicant PII, full retention policy applies
Webhook endpointPoints at a tunnel or a staging URLPoints at your production handler, TLS-terminated
WorkflowMirrors prod config, but safe to breakChange-controlled, reviewed before edits

Warning

A "test" session created against your production tenant still costs a real credit and stores real evidence if the applicant submits real documents. Always test against a dedicated non-production tenant — see Testing.

Webhooks and signature verification

Signature verification is implemented, verifies over the raw body, uses a constant-time compare, and rejects timestamps older than 5 minutes.
A bad signature returns 4xx and does nothing. You've tested this by flipping a hex character.
The handler acknowledges in milliseconds and does its work on a queue.
Handling is idempotent (deduped on idempotency_key) and order-independent.
You handle all four session events — approved, declined, review, expired — and, if you use flows, identity.application.*.

See verifying signatures for the reference code and Verify a user, end to end for a full worked handler in Node and Python.

Correctness

Every gate — account activation, transfers, access — keys off the webhook or a server-side decision read, never a client result.
workflow_id is pinned explicitly on POST /v3/session/, not left to the tenant default.
You branch on decision / application.decision, not on raw statuses.
KYC session payloads are read as flatevent.vendor_data, event.decision, event.session_id — with no nested session object.
Unknown terminal statuses are treated as in_review, not as a pass.
in_review is modelled as a real, possibly long-lived state with a later resolution.

Idempotency and resilience

Idempotency-Key on every POST /v3/session/; a stable, unique jti on every partner JWT for POST /api/v1/sessions/.
429 and 5xx responses are retried with exponential backoff and jitter.
A reconciliation sweep re-reads sessions still pending after N minutes, in case a webhook was missed.
402 insufficient_credits degrades gracefully for the user rather than 500-ing.

A minimal backoff-and-retry wrapper, since 429/5xx handling is easy to skip under deadline pressure:

async function createSessionWithRetry(body: object, maxAttempts = 4) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = await fetch(`${BASE}/v3/session/`, {
      method: "POST",
      headers: { "x-api-key": KEY, "content-type": "application/json", "idempotency-key": body.idempotencyKey },
      body: JSON.stringify(body),
    });

    if (res.ok) return res.json();
    if (res.status === 402) throw new Error("insufficient_credits");   // don't retry — won't fix itself
    if (res.status === 429 || res.status >= 500) {
      const delayMs = Math.min(1000 * 2 ** attempt, 8000) + Math.random() * 250;
      await new Promise((r) => setTimeout(r, delayMs));
      continue;
    }
    throw new Error(`obsidian ${res.status}: ${await res.text()}`);    // 400/401 — don't retry
  }
  throw new Error("obsidian: exhausted retries");
}
import time, random, requests

def create_session_with_retry(body: dict, max_attempts: int = 4) -> dict:
    for attempt in range(1, max_attempts + 1):
        resp = requests.post(
            f"{BASE}/v3/session/",
            headers={"x-api-key": KEY, "Idempotency-Key": body["idempotency_key"]},
            json=body,
            timeout=15,
        )
        if resp.ok:
            return resp.json()
        if resp.status_code == 402:
            raise RuntimeError("insufficient_credits")       # don't retry — won't fix itself
        if resp.status_code == 429 or resp.status_code >= 500:
            delay = min(1 * 2 ** attempt, 8) + random.uniform(0, 0.25)
            time.sleep(delay)
            continue
        resp.raise_for_status()                              # 400/401 — don't retry
    raise RuntimeError("obsidian: exhausted retries")

Note

Retry 429 and 5xx only. 400/401/402 are not transient — retrying them just burns your rate-limit bucket for no benefit. See Rate limits and Errors.

Credits and operations

Credit balance is monitored with an alert above zero, not at it — creating a session debits the SESSION_CREATE rate and fails with 402 when the balance is exhausted.
Someone owns the Verifications review queue with an SLA. AML fails closed: if screening is down, everything lands in review and the queue becomes the bottleneck.
The operator team knows what "Continue anyway" (bypass) means on a flagged session, and applicant-facing copy for in_review and declined has been written deliberately.

The 402 body is a stable code, not a message to string-match:

{ "detail": "insufficient_credits" }

Check the balance proactively rather than waiting for a 402 in production:

curl -H "x-api-key: $TF_API_KEY" "$BASE/v3/credits/ledger/?page_size=1"

Choosing modules

The workflow you pin with workflow_id decides which modules run. Design it in Console → Config → KYC Workflows and confirm:

The enabled modules match your risk appetite — document verification, liveness, face match, and AML/PEP screening are independent toggles.
The liveness method is set intentionally (passive, colour-flash, or 3D-action). The server enforces it; it cannot be downgraded client-side.
Consent copy shown in the flow has been reviewed by legal.
You've verified the exact modules by reading GET /v3/workflows/ and confirming features for the workflow you pinned.

See Common use cases for typical module combinations by vertical, and KYC modules for what each one does and costs.

Compliance

You know where evidence (documents, selfies, liveness stills) is stored and who can read it. It is PII, served only through the authenticated, ownership-checked console view.
Retention and deletion are agreed. DELETE /v3/session/<id>/delete/ soft-deletes a session and its evidence (returns 204).
You understand that an approved customer can surface as an AML hit later via the rolling re-screen, and someone must act on it.

Troubleshooting

Warning

"My webhook fires but nothing happens downstream." The most common cause is reading event.session.vendor_data — KYC session payloads are flat, there is no nested session object. Read event.vendor_data at the top level.

Warning

"Every signature check fails in production but works locally." Almost always a body-parsing mismatch: something in your framework (a proxy, a logging middleware, express.json()) re-serializes the request body before your handler sees it. HMAC verification needs the exact raw bytes Obsidian sent — mount your webhook route with a raw-body parser, before any JSON middleware.

Warning

"Sessions silently approve without running a module." They don't — a required evidence-driven module that never produces a terminal result leaves the session in_progress or routes to review, it never fabricates a pass. If you're seeing unexpected approvals, re-check which modules GET /v3/workflows/ actually reports for the workflow_id you're pinning; it's easy to pin a lighter workflow than the one an operator is looking at in the console.

FAQ