Reference

Troubleshooting

Symptom-first fixes for the problems integrators actually hit — stuck sessions, missing webhooks, signature failures, 401/402 errors, camera and NFC issues, expiry, redirects, and duplicates.

This page is organized by symptom: find the thing you're seeing, read the likely causes, and work the fix steps. For the reference material behind these fixes, each section links to the page that covers it in full. If you're asking "how does X work?" rather than "why is X broken?", start at FAQ; for the raw status/code reference, see Errors.

Tip

Almost every problem below is faster to diagnose from Console → Verifications (open the session and read its logs timeline) and Console → Developers → Webhooks (delivery attempts and responses) than from your own logs. Check the session's server-side state before assuming your code is at fault.

Session stuck in not_started

A session was created (201) but never progresses — GET /v3/session/<id>/ keeps returning status: "not_started".

CauseHow to confirmLikelihood
The applicant never opened the hosted urlThe session logs have no page-open event✅ Most common
You redirected to the wrong URL (built one yourself instead of using the returned url)Your redirect target isn't the .../verify/<session_token> URL from the create response⚠️ Common
The applicant opened the link but bounced before the first step (blocked pop-up, closed tab)A page-open event but nothing after it⚠️ Common
You're polling a different session than the one the applicant is using (duplicate create)Two sessions with the same vendor_data, one progressing⚠️ See duplicates

Fix steps

Confirm you handed over the exact returned url

not_started means the flow was never entered. Redirect to the url field from the POST /v3/session/ response verbatim — it's an absolute URL on your tenant's flow domain. Never reconstruct it from the session id.

Check the session did not silently expire

If expires_at has passed, the session moves to expired, not stuck — see session expired immediately. not_started with a future expires_at means the link is valid and simply hasn't been opened.

Read the logs timeline

GET /v3/session/<id>/logs/ returns a chronological event list. No events after create = the applicant hasn't opened the link (resend it). Events that stop mid-flow = they abandoned; the session will move to abandoned or expired on its own.

Note

not_started is not an error and never fires a webhook. Don't build alerting that treats a fresh, unopened session as a failure — model it as "awaiting applicant" and let it settle to a terminal status or expired.

Webhook never arrives

A session reached a terminal status (approved / declined) in the console, but your endpoint never received the event.

CauseHow to confirmFix
No webhook URL configured for the tenantConsole → Developers → Webhooks shows no destinationAdd the URL
Endpoint not publicly reachable (localhost, private IP, VPN-only)Delivery attempt logged with a connection errorExpose a public HTTPS URL / use a tunnel
SSRF guard rejected the destination (loopback / private / link-local)Delivery marked rejected before any attemptUse a public, non-private address
Endpoint returned non-2xx, exhausted retriesDelivery log shows repeated 4xx/5xxFix the handler, then resend
Only a non-terminal status was reached (e.g. in_review)Session sits in in_reviewWait for operator resolution

Fix steps

Verify a destination is configured and reachable

The destination must be publicly reachable over HTTPS and must pass an SSRF guard that rejects private, loopback, and link-local addresses — you cannot point webhooks straight at http://localhost. For local development, run a tunnel and set its public HTTPS URL in the console. See Testing → webhook development.

Acknowledge fast, before your business logic

Delivery is at-least-once with retries, but the sender's timeout starts at connect — not when your DB write finishes. Return 2xx immediately, then process asynchronously. A handler that does slow work before responding looks "down" and gets retried (and eventually gives up).

Resend from the console

Once your endpoint is healthy, resend any delivery from Console → Developers → Webhooks. Nothing is lost as long as the endpoint recovers within the retry window.

Check whether the status is actually terminal

An in_review session (AML/PEP hit, low-confidence check, forgery signal) hasn't produced a terminal decision yet, so no approved/declined webhook has fired. It will settle later when an operator resolves it. The absence of a terminal webhook is not a delivery failure.

Webhook signature verification fails

Deliveries arrive, but your X-Webhook-Signature check rejects every one.

CauseHow to confirmLikelihood
Verifying re-serialized JSON instead of the raw bytesYou parse the body (global JSON middleware) then re-stringify to verify✅ By far the most common
Wrong secret (mismatched tenant, or rotated since deploy)Signature never matches even on a known-good raw body⚠️ Common
No secret saved for the tenant — deliveries sent unsignedThe header is absent, not wrong⚠️ Common
Server clock skew > 5 minutesFresh deliveries fail the staleness check⚠️ Occasional
Non-constant-time compare masking a real match/mismatch❌ Rare but a security bug regardless

Fix steps

Sign over the exact raw request body

The signature is HMAC-SHA256(secret, "<t>." + raw_body). If a framework parsed the body into an object before your handler ran and you re-serialize it, whitespace and key order change and every signature fails. Capture the raw bytes before any JSON parsing — in Express, mount express.raw({ type: "application/json" }) on the webhook route before any global express.json().

Confirm the secret matches the tenant

Use the secret from Console → Developers → Webhooks for the same tenant that sent the event. Rotation takes effect immediately, so a rotate that hasn't been deployed to your server yet fails until you push the new secret.

Treat a missing header as a bad signature

If no signing secret is saved for the tenant, Obsidian sends the delivery with no X-Webhook-Signature header at all. Reject missing-header deliveries the same as mis-signed ones, and configure a secret before production.

Sync your clock and compare in constant time

Keep the server NTP-synced (the check rejects t more than 5 minutes from now), and compare with hmac.compare_digest / crypto.timingSafeEqual / hmac.Equal — never ==.

Warning

The single most common cause here is a global JSON parser stealing the raw body. If verification "always fails" and the secret is definitely correct, this is almost certainly it. Full walkthrough: Verifying signatures.

402 insufficient_credits on every request

Every POST /v3/session/ (or standalone tool call) returns 402.

CauseFix
Tenant credit balance is genuinely exhaustedTop up credits
Retrying in a loop against an empty balanceStop retrying 402; alert your team instead
Testing on your production tenant and draining itMove test traffic to a non-production tenant

Fix steps

Confirm the balance, don't retry

402 insufficient_credits won't resolve on retry — it clears only when the balance is topped up. Surface a graceful "verification temporarily unavailable" state to the applicant; don't 500 at them and don't loop retries.

Read credits_remaining from your own responses

POST /v3/session/ and POST /api/v1/sessions/ return credits_remaining — the cheapest possible balance check since you're already making the call. Alert when it crosses a floor you set, before the first 402.

Reconcile spend if the drain is unexpected

GET /v3/usage/workflows/ aggregates spend per workflow and GET /v3/credits/ledger/ gives the line-by-line debit history (creating a session debits the SESSION_CREATE rate plus each chargeable module the workflow runs). See Credits.

401 on POST /v3/session/ (wrong auth header / surface)

The key is valid but the request is rejected with 401 — often only on one surface.

CauseHow to confirmFix
Wrong header for the surface401 on /v3 but the same key works on /api/v1 (or vice versa)Match the header to the surface
Key rotated / revoked in the console401 on both surfacesIssue and deploy a new key
Key sent from a browser/mobile client (leaked or blocked)Request originates client-sideMove key to your backend
Malformed header (extra Bearer, stray whitespace, wrong case)Fix the header value

Fix steps

Match the header to the surface

It's the same tenant key, sent differently: x-api-key: <key> on the ThirdFactor Verify API (/v3), and Authorization: Bearer <key> on the partner API (/api/v1). A 401 on only one surface almost always means a header-name bug, not a bad key — a key that authenticates on one surface authenticates on the other.

Rule out rotation

A 401 on both surfaces means the key itself is missing, malformed, or was rotated/revoked. Create a new key in Console → Settings → API keys and deploy it. You can run the old and new keys in parallel during cutover.

Keep the key server-side

The key is a secret. If it's in a browser bundle or mobile binary, treat it as leaked and rotate immediately — the SDKs never need it; they only receive the url your backend minted. See Authentication.

Camera doesn't open in the hosted flow

The applicant reaches document capture or liveness and the camera never activates.

CauseHow to confirmFix
Page not served over HTTPS (or a mixed-content wrapper)Browsers block getUserMedia on insecure originsServe the flow over HTTPS
Camera permission denied / dismissed by the userBrowser shows the permission as blockedRe-prompt or guide the user to reset it
Embedded in an iframe/WebView without camera permission delegatedWorks in a full tab, fails when embeddedGrant allow="camera; microphone" on the iframe / native WebView
Another app or tab already holds the cameraOS-level camera-in-use errorClose the other consumer

Fix steps

Serve over HTTPS

Camera access requires a secure origin. The url Obsidian returns is already HTTPS on your flow domain — don't proxy it through an insecure origin or downgrade the scheme.

Delegate camera permission when embedding

If you render the hosted flow inside an iframe or a native WebView, the parent must delegate camera (and microphone, for liveness) permission to it — e.g. allow="camera; microphone" on a web iframe, or the platform's WebView permission callback on mobile. Without delegation the inner page can't prompt at all. See SDKs overview.

Have the user reset a denied permission

Once a browser permission is denied for the origin, the flow can't re-prompt programmatically — the user has to re-enable camera access for the site in their browser settings and reload.

NFC read times out / chip not found

An NFC_VERIFICATION step is running on a native SDK and the scan fails.

res.error.codeMeaningWhat to do
timeoutChip not held long enough / poor antenna contactAsk the user to hold the phone still against the document and retry
tag_lostContact broke mid-readRetry; keep the phone flat on the document
wrong_keyBAC/PACE key derivation failed (bad MRZ/CAN inputs)Re-check the OCR'd documentNumber + dateOfBirth + dateOfExpiry
nfc_disabledNFC radio is off (Android)Prompt the user to enable NFC
user_cancelledUser dismissed the scan sheetRe-offer the step

Fix steps

Confirm the device can even do NFC

NFC needs the device's NFC radio, which a WebView can't reach — so it's iOS, Android, and Flutter only. On web (or a device without an NFC reader), an NFC_VERIFICATION step transparently falls back to server-side manual review (nfc_not_available) instead of blocking the applicant. If you expected an on-device read and got a fallback, check the host actually advertises the nfc capability. See NFC verification.

Feed the reader the right key inputs

The BAC key is derived from the MRZ — documentNumber, dateOfBirth, dateOfExpiry (each YYMMDD for the dates) — or a CAN for PACE. A wrong_key error almost always means one of those came from a bad OCR read; re-derive them from the confirm-details prefill or the scanned document and retry.

Coach the physical scan

timeout and tag_lost are handling problems, not software bugs: the chip sits in a specific spot (often the top of a passport's photo page), and phones vary in antenna placement. Tell the user to lay the phone flat and hold still. Retrying is safe.

Note

An integrity-verified chip still resolves to in_review (not approved) unless a CSCA masterlist is configured for the tenant — that's expected behavior, not a bug. A hash mismatch (tampered chip) resolves to declined. See the decision mapping in NFC verification.

Applicant sees "session expired" immediately

The applicant opens the link and is told the session has already expired.

CauseHow to confirmFix
The link is genuinely old (created long before it was opened)expires_at is in the pastMint a fresh session
Very short expires_in_hours / session_ttl_minutes at createCreate call used a tiny TTLUse a longer lifetime
The URL was already opened and completed once (single-use)Session is already terminalCreate a new session to retry
Server/client clock skew makes a valid link look expiredRare; check expires_at vs. real timeSync clocks

Fix steps

Check expires_at on the session

The hosted URL is time-boxed and single-use. For /v3, lifetime is set by expires_in_hours (1..720, default 48); for /api/v1, by session_ttl_minutes (or the tenant default). Once it lapses the session moves to expired and the URL stops working — there's no resume.

Don't pre-generate links far in advance

If you email a link now and the applicant clicks it days later, it may be past expires_at. Create the session close to when the applicant will actually use it, or set a lifetime that comfortably covers your delivery + response window.

Mint a new session to retry

An expired (or already-completed) session can't be reopened. Create a fresh one — reuse the same vendor_data so it still links back to the same identity/user. For a still-valid /api/v1 application you can extend the current URL with PATCH /api/v1/applications/<id>/ before it lapses, but never after.

callback_url redirect lands on an error page

The applicant finishes the flow and is redirected to your callback_url, but your page errors.

CauseHow to confirmFix
Your handler reads the decision from redirect query paramsThe redirect carries no authoritative decision to trustRead the decision server-side instead
callback_url route doesn't exist / 404sDirect-hitting the URL 404sDeploy the route
Handler assumes a terminal decision that isn't in yet (in_review)Redirect happens before an operator resolves reviewDon't gate the page on a terminal decision
Relative or malformed callback_urlIt isn't an absolute HTTPS URLPass an absolute URL

Fix steps

Treat the redirect as UX only, not as the decision

The callback_url return is a "you're done" signal for the applicant — it is not an authoritative, signed decision. Never grant access or move value based on the redirect alone. Gate real actions on the HMAC-signed webhook or a server-side GET /v3/session/<id>/decision/ read keyed by vendor_data.

Render a neutral landing state

The applicant may land back on your page while the session is still in_progress or in_review. Show a neutral "thanks, we're finishing up" page and resolve the real outcome asynchronously from the webhook — don't try to render approved/declined from the redirect.

Pass an absolute, reachable URL

callback_url must be an absolute URL you actually serve. Test it by hitting it directly before wiring it into the create call.

Duplicate sessions created

Two (or more) sessions show up for what should have been one applicant, and credits were debited more than once.

CauseHow to confirmFix
Retried POST /v3/session/ after a timeout without an Idempotency-KeyTwo 201s, two ids, same vendor_dataSend an Idempotency-Key
Idempotency key derived from mutable data (timestamp per retry)Keys differ across retriesUse a stable key
Client double-submit (double click, double effect) racing two createsTwo near-simultaneous createsDebounce + idempotency
Reusing the same jti across retries not respected/api/v1 retries with a changing jtiKeep jti stable per logical session

Fix steps

Always send an Idempotency-Key on /v3 create

Without it, a retried POST /v3/session/ after a timeout creates a second session and debits credits again — the server can't tell it's a retry. With it, a repeat create with the same key returns the original session (201) and is never charged twice.

Derive the key from something stable

Generate the key from data that doesn't change across retries — a queue message ID, or a user + intent tuple — not from a per-attempt timestamp. A key that changes on retry gives you no replay protection. On /api/v1/sessions/, the JWT's jti plays the same role; keep it stable per logical session.

Reconcile by vendor_data, not by session id

If duplicates already exist, join them back to your user via vendor_data / external_user_id (the field designed for correlation) and pick the one the applicant actually completed. See Errors → idempotency.

FAQ