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".
| Cause | How to confirm | Likelihood |
|---|---|---|
The applicant never opened the hosted url | The 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.
| Cause | How to confirm | Fix |
|---|---|---|
| No webhook URL configured for the tenant | Console → Developers → Webhooks shows no destination | Add the URL |
| Endpoint not publicly reachable (localhost, private IP, VPN-only) | Delivery attempt logged with a connection error | Expose a public HTTPS URL / use a tunnel |
| SSRF guard rejected the destination (loopback / private / link-local) | Delivery marked rejected before any attempt | Use a public, non-private address |
Endpoint returned non-2xx, exhausted retries | Delivery log shows repeated 4xx/5xx | Fix the handler, then resend |
Only a non-terminal status was reached (e.g. in_review) | Session sits in in_review | Wait 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.
| Cause | How to confirm | Likelihood |
|---|---|---|
| Verifying re-serialized JSON instead of the raw bytes | You 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 unsigned | The header is absent, not wrong | ⚠️ Common |
| Server clock skew > 5 minutes | Fresh 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.
| Cause | Fix |
|---|---|
| Tenant credit balance is genuinely exhausted | Top up credits |
| Retrying in a loop against an empty balance | Stop retrying 402; alert your team instead |
| Testing on your production tenant and draining it | Move 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.
| Cause | How to confirm | Fix |
|---|---|---|
| Wrong header for the surface | 401 on /v3 but the same key works on /api/v1 (or vice versa) | Match the header to the surface |
| Key rotated / revoked in the console | 401 on both surfaces | Issue and deploy a new key |
| Key sent from a browser/mobile client (leaked or blocked) | Request originates client-side | Move 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.
| Cause | How to confirm | Fix |
|---|---|---|
| Page not served over HTTPS (or a mixed-content wrapper) | Browsers block getUserMedia on insecure origins | Serve the flow over HTTPS |
| Camera permission denied / dismissed by the user | Browser shows the permission as blocked | Re-prompt or guide the user to reset it |
| Embedded in an iframe/WebView without camera permission delegated | Works in a full tab, fails when embedded | Grant allow="camera; microphone" on the iframe / native WebView |
| Another app or tab already holds the camera | OS-level camera-in-use error | Close 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.code | Meaning | What to do |
|---|---|---|
timeout | Chip not held long enough / poor antenna contact | Ask the user to hold the phone still against the document and retry |
tag_lost | Contact broke mid-read | Retry; keep the phone flat on the document |
wrong_key | BAC/PACE key derivation failed (bad MRZ/CAN inputs) | Re-check the OCR'd documentNumber + dateOfBirth + dateOfExpiry |
nfc_disabled | NFC radio is off (Android) | Prompt the user to enable NFC |
user_cancelled | User dismissed the scan sheet | Re-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.
| Cause | How to confirm | Fix |
|---|---|---|
| The link is genuinely old (created long before it was opened) | expires_at is in the past | Mint a fresh session |
Very short expires_in_hours / session_ttl_minutes at create | Create call used a tiny TTL | Use a longer lifetime |
| The URL was already opened and completed once (single-use) | Session is already terminal | Create a new session to retry |
| Server/client clock skew makes a valid link look expired | Rare; check expires_at vs. real time | Sync 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.
| Cause | How to confirm | Fix |
|---|---|---|
| Your handler reads the decision from redirect query params | The redirect carries no authoritative decision to trust | Read the decision server-side instead |
callback_url route doesn't exist / 404s | Direct-hitting the URL 404s | Deploy the route |
Handler assumes a terminal decision that isn't in yet (in_review) | Redirect happens before an operator resolves review | Don't gate the page on a terminal decision |
Relative or malformed callback_url | It isn't an absolute HTTPS URL | Pass 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.
| Cause | How to confirm | Fix |
|---|---|---|
Retried POST /v3/session/ after a timeout without an Idempotency-Key | Two 201s, two ids, same vendor_data | Send an Idempotency-Key |
| Idempotency key derived from mutable data (timestamp per retry) | Keys differ across retries | Use a stable key |
| Client double-submit (double click, double effect) racing two creates | Two near-simultaneous creates | Debounce + idempotency |
Reusing the same jti across retries not respected | /api/v1 retries with a changing jti | Keep 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.