The envelope
Every failure is JSON with a stable, machine-readable error string:
{
"error": "invalid_request",
"message": "Human-readable detail. Safe to log, not safe to show a user verbatim.",
"fields": { "theme.accent": "must be a CSS colour, e.g. #1a90cc or rgb(26 144 204)" }
}
error— branch on this. It does not change without a version bump.message— present on most errors. Written for your logs.fields— present on validation failures only. Keys are field paths, so a form can attach each message to the right input.
Status codes
| Status | Meaning | Retry? |
|---|---|---|
400 | The request is malformed or a field is invalid. | No — fix the request. |
401 | The key or session is missing, wrong, or revoked. | No. |
402 | Out of credits. | After topping up. |
403 | Authenticated but not permitted: origin not allowlisted, or a bad URL signature. | No. |
404 | No such object, or it belongs to another tenant. | No. |
409 | Conflict: an already-used client token, or an email that is taken. | No. |
410 | The session expired. | No — create a new session. |
413 | The upload is larger than the limit (25 MB by default). | No. |
429 | Rate limited. | Yes, with backoff. |
5xx | Our fault. | Yes, with backoff. |
404 covering “belongs to someone else” is deliberate. Returning 403 there would confirm that an id
exists, which is an enumeration oracle.
Error codes
Authentication and keys
| Code | Status | Cause |
|---|---|---|
invalid_api_key | 401 | The Authorization: Bearer … header is absent, malformed, unknown, or the key was revoked. |
secret_key_required | 401 | The endpoint needs an sk_ key and you sent a pk_. Reading a session is the usual case. |
invalid_credentials | 401 | Console login failed. Identical for an unknown email and a wrong password, on purpose. |
unauthorized | 401 | No valid console session cookie on a /v1/dashboard/* route. |
email_taken | 409 | Signup with an email that already has an account. |
Sessions and scanning
| Code | Status | Cause |
|---|---|---|
origin_not_allowed | 403 | A pk_ key was used from an origin not on your allowlist, or allowed_origins named an origin that is not. |
insufficient_credits | 402 | Zero balance. No session was created. |
invalid_scanner_config | 400 | The scanner override failed validation. See fields. No session was created. |
invalid_token | 401 / 400 | The client token is malformed, tampered with, or expired. |
session_expired | 410 | The session passed its TTL (15 minutes by default). |
token_already_used | 409 | The client token already uploaded a document. One session, one document. |
empty_file | 400 | The upload had no bytes. |
file_too_large | 413 | Above MAX_UPLOAD_BYTES. |
invalid_signature | 403 | A file URL’s sig or exp does not verify. |
rate_limited | 429 | Too many session creations in the window. |
Console
| Code | Status | Cause |
|---|---|---|
invalid_request | 400 | Validation failed. Always accompanied by fields. |
not_found | 404 | No such key, domain or delivery — or it is another tenant’s. |
no_endpoint | 400 | Sending a test webhook with no endpoint configured. |
Which errors to retry
Retry with exponential backoff: 429 and 5xx. Nothing else.
Never retry: 400, 401, 403, 409, 410, 413. The same request will fail identically, and
retrying a 402 without topping up just wastes calls.
A special case — 410 session_expired: do not retry the upload. Create a new session and let the
user scan again. That costs another credit, which is worth surfacing in your own metrics.
async function createSession(body: unknown, attempt = 0): Promise<Response> {
const res = await fetch("https://api.instantscan.io/v1/sessions", {
method: "POST",
headers: { Authorization: `Bearer ${SECRET_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const retryable = res.status === 429 || res.status >= 500;
if (retryable && attempt < 3) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
return createSession(body, attempt + 1);
}
return res;
}
Errors in the browser
The SDK never rejects with a raw HTTP error. Everything becomes a ScannerError with a code of
cancelled, config, network or scan_error — see
Embed SDK → Errors.
The important one: cancelled is not a failure. A user changing their mind is normal, and treating
it as an error is the most common integration bug we see.
Errors in webhook delivery
If your endpoint returns non-2xx or times out, the delivery is retried up to three times with backoff. Every attempt is recorded with its response status, duration and transport error, visible in Webhooks → Deliveries.
There is no dead-letter queue. When attempts are exhausted the delivery is marked failed and stays in
the log, where you can inspect the exact signed body and replay it once your endpoint is fixed.
Debugging checklist
| Symptom | First thing to check |
|---|---|
403 origin_not_allowed in the browser | Is the exact origin — scheme, host and port — on your allowlist? http://localhost:3000 and http://127.0.0.1:3000 are different origins. |
401 invalid_api_key on a working key | Was it revoked? Are you sending a live key to a test-mode integration, or vice versa? |
409 token_already_used | You are reusing a scan_url. Create one session per document. |
| Scanner opens then immediately errors | Check GET /v1/scanner/config?t=… directly. A 410 means the session expired before the user got there. |
| Webhook signature never matches | You are verifying a re-serialised body instead of the raw bytes. |
| Nothing at all happens on click | Look for a ScannerError in the console. Almost always config (session refused) or network (wrong apiBase). |