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
.env.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 tenant | Production tenant | |
|---|---|---|
| API keys | Separate keys, safe to share with CI | Restricted to prod secrets manager |
| Credits | Test balance, safe to drain | Real balance — monitor with alerts |
| Evidence (documents, selfies) | Real PII from test runs — still needs deletion hygiene | Real applicant PII, full retention policy applies |
| Webhook endpoint | Points at a tunnel or a staging URL | Points at your production handler, TLS-terminated |
| Workflow | Mirrors prod config, but safe to break | Change-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
4xx and does nothing. You've tested this by flipping a hex character.idempotency_key) and order-independent.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
workflow_id is pinned explicitly on POST /v3/session/, not left to the tenant default.decision / application.decision, not on raw statuses.event.vendor_data, event.decision, event.session_id — with no nested session object.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.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
SESSION_CREATE rate and fails with 402 when the balance is exhausted.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:
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
DELETE /v3/session/<id>/delete/ soft-deletes a session and its evidence (returns 204).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.