Base URL: https://api.instantscan.io. Running the stack locally, http://localhost:8787.
All request and response bodies are JSON unless stated otherwise. All timestamps are ISO 8601 in UTC.
Every sample below uses the seeded demo keys (pk_test_demo, sk_test_demo), which are test-mode.
They debit the demo tenant’s credits like any other session.
Authentication
There are three separate credentials, and mixing them up is the most common source of a 401.
| Credential | Header | Can do |
|---|---|---|
Publishable key pk_… | Authorization: Bearer pk_test_demo | Create a session, and only from an allowlisted Origin. Nothing else. |
Secret key sk_… | Authorization: Bearer sk_test_demo | Create a session, read a session and its result. Server-side only. |
| Console session | Cookie: sc_session=… | Everything under /v1/dashboard/*. Set by POST /v1/auth/login; httpOnly, so it is not readable from JavaScript. |
The scanner runtime endpoints use a fourth thing that is not a credential you manage: the client
token minted with each session, passed as ?t=…. It is signed, expires in minutes, and is single-use
for the upload.
Errors are documented in full under Errors.
Public API
The surface you integrate against.
GET /v1/health
Liveness probe. No authentication.
curl https://api.instantscan.io/v1/health
{ "service": "instantscan.io", "status": "ok", "version": 1 }
POST /v1/sessions
Create a scan session. Auth: publishable or secret key.
With a publishable key the request’s Origin must be on your allowlist, and every entry in
allowed_origins must be too. With a secret key no Origin is required.
Creating a session is free — a credit is debited later, when a document is actually scanned (see
POST /v1/scanner/upload), whether the key is test or live. This call still returns
402 insufficient_credits when the balance is below one credit, so an integration that cannot pay
for a scan fails here rather than after the user has scanned; the check debits nothing.
| Body field | Type | Required | Meaning |
|---|---|---|---|
metadata | object | no | Opaque JSON echoed back in the session, the webhook and analytics. |
scanner_id | string | no | Named scanner profile to use. Defaults to the account’s default profile. |
scanner | object | no | Per-session customization, deep-merged over the selected profile. See Customization. |
allowed_origins | string[] | no | Origins permitted to embed this session and receive its result. Defaults to your account allowlist. |
callback_url | string | no | Webhook destination for this session only, overriding the account endpoint. |
curl -X POST https://api.instantscan.io/v1/sessions \
-H "Authorization: Bearer sk_test_demo" \
-H "Content-Type: application/json" \
-d '{
"metadata": { "application_id": "app_1042" },
"scanner": { "locale": "fr", "options": { "maxPages": 2 } }
}'
201 Created
{
"id": "ss_9Qw1p2Kt",
"mode": "test",
"client_token": "eyJzaWQiOiJzc185UXcxcDJLdCIsInRpZCI6InRuX2FjbWUiLCJpYXQiOjE3MzU2ODk2MDAwMDB9.1f8c…",
"scan_url": "https://scan.instantscan.io/?t=eyJzaWQiOiJzc185UXcxcDJLdCIsInRpZCI6InRuX2FjbWUi…",
"expires_at": "2026-01-01T00:15:00.000Z"
}
mode reflects the key you authenticated with, not anything you sent. It is the value to assert on
in a deploy check: a live session where you expected test means a production key reached your
staging configuration.
Errors: 401 invalid_api_key, 403 origin_not_allowed, 402 insufficient_credits,
400 invalid_scanner_config, 429 rate_limited.
GET /v1/sessions/:id
Read a session and, once complete, a signed URL for its document. Auth: secret key only — a
publishable key gets 401 secret_key_required.
This is how a desktop page waits on a QR hand-off.
curl https://api.instantscan.io/v1/sessions/ss_9Qw1p2Kt \
-H "Authorization: Bearer sk_test_demo"
{
"id": "ss_9Qw1p2Kt",
"status": "completed",
"mode": "test",
"metadata": { "application_id": "app_1042" },
"created_at": "2026-01-01T00:00:00.000Z",
"expires_at": "2026-01-01T00:15:00.000Z",
"result": {
"url": "https://api.instantscan.io/v1/files/res_7Xk3?exp=1735776000000&sig=9c21…",
"page_count": 3,
"bytes": 412778,
"content_type": "application/pdf"
}
}
status is one of created, opened, scanning, completed, delivered, failed, expired.
result is null until the document has been uploaded.
Another tenant’s session id returns 404 not_found, not 403.
GET /v1/sessions/:id/status
Poll for completion from the browser with the session’s client_token. Auth: ?t=… — no secret
key required. This is what the embed SDK uses on desktop during a QR hand-off.
curl "https://api.instantscan.io/v1/sessions/ss_9Qw1p2Kt/status?t=eyJzaWQiOiJzc185UXcxcDJLdCI…"
{
"id": "ss_9Qw1p2Kt",
"status": "completed",
"expires_at": "2026-01-01T00:15:00.000Z",
"result": {
"url": "https://api.instantscan.io/v1/files/res_7Xk3?exp=1735776000000&sig=9c21…",
"page_count": 3,
"bytes": 412778,
"content_type": "application/pdf"
}
}
Returns 401 invalid_token for a bad token, 404 not_found when the token does not match the
session id, and 410 session_expired after the session TTL.
GET /v1/sessions/:id/qr
SVG QR code for the session’s scan_url. Auth: client token as ?t=…. Used by the embed SDK
on desktop; you can also point an <img> at it directly.
curl "https://api.instantscan.io/v1/sessions/ss_9Qw1p2Kt/qr?t=eyJzaWQiOiJzc185UXcxcDJLdCI…" \
-H "Accept: image/svg+xml"
Returns image/svg+xml with Cache-Control: private, no-store. Same error codes as
/v1/sessions/:id/status.
GET /v1/scanner/config
What the hosted scanner boots from. Auth: the client token as ?t=…, or the short signed
hand-off code as ?c=… that a QR carries (see QR hand-off).
You rarely call this yourself, but it is the fastest way to confirm that a customization override
actually landed. Calling it moves a created session to opened.
| Query | Type | Required |
|---|---|---|
t | string | The session’s client_token. |
c | string | A hand-off code from the QR. Supply exactly one of t or c. |
When opened with ?c=…, the response includes the matching client_token so the QR-launched
scanner has the credential it needs to upload and complete — the code alone resolves the session,
but the token is what finishes the scan.
curl "https://api.instantscan.io/v1/scanner/config?t=eyJzaWQiOiJzc185UXcxcDJLdCI…"
{
"session_id": "ss_9Qw1p2Kt",
"client_token": "eyJzaWQiOiJzc185UXcxcDJLdCI…",
"upload": {
"url": "https://blob.vercel-storage.com/scans/ss_9Qw1p2Kt.pdf?token=…",
"content_type": "application/pdf",
"expires_at": "2026-01-02T00:30:00.000Z",
"max_bytes": 26214400
},
"complete_url": "https://api.instantscan.io/v1/scanner/complete",
"upload_url": "https://api.instantscan.io/v1/scanner/upload",
"scanner": {
"locale": "fr",
"theme": { "accent": "#1a90cc", "cornerRadius": 12, "brandName": "Acme Lending" },
"completion": { "message": "Document received." },
"options": {
"maxPages": 2,
"output": "pdf",
"grayscale": false,
"showFlash": true,
"showZoom": true,
"showA4Hint": true
},
"watermark": { "enabled": false, "color": "#334155", "opacity": 0.18, "size": "md" }
}
}
Errors: 401 invalid_token, 410 session_expired.
upload is the path the hosted scanner takes: PUT the bytes to upload.url, then call
complete_url. Two steps rather than one because a document does not fit through the API — the
platform caps a request body at 4.5 MB while a scan may be 25 MB — and because bytes that skip the
API are bytes exposed to one fewer system. upload.url is short-lived, accepts only
upload.content_type, refuses anything over upload.max_bytes, and writes to a location derived
from the session, so it grants nothing beyond finishing this one scan.
upload_url is the single-request alternative, subject to that 4.5 MB cap. Use it only for small
documents or when you cannot make two requests.
PUT /v1/scanner/blob
The storage endpoint used when the platform is configured to keep documents in-process, which is the default for local development. Auth: the URL signature.
You never build this URL. It arrives as upload.url from the scanner config, and in a hosted
deployment it points at object storage rather than at this API. Send the raw bytes as the request
body with a matching Content-Type.
curl -X PUT --data-binary @scan.pdf \
-H "Content-Type: application/pdf" \
"http://localhost:8787/v1/scanner/blob?key=scans%2Fss_9Qw1p2Kt.pdf&exp=…&sig=…"
Responds 204 with no body. Errors: 403 invalid_signature, 400 empty_file,
413 file_too_large.
POST /v1/scanner/complete
Tell the API the upload landed. Auth: client token in the body, single-use.
The size and content type are read back from storage, so what the ledger and the webhook report is
the document that actually exists rather than what this request claims. pages is metadata only.
| Body field | Type | Meaning |
|---|---|---|
session | string | The client_token. |
pages | number | Page count, for analytics. |
curl -X POST https://api.instantscan.io/v1/scanner/complete \
-H "Content-Type: application/json" \
-d '{"session":"eyJzaWQiOiJzc185UXcxcDJLdCI…","pages":3}'
Returns the same body as POST /v1/scanner/upload, and has the same side effects: the session
becomes completed, a usage event is recorded, and a signed scan.completed webhook is dispatched.
Errors: 400 invalid_token, 409 upload_not_found when nothing was uploaded,
410 session_expired, 409 token_already_used.
POST /v1/scanner/upload
Deliver the finished document in a single request. Auth: client token in the body, single-use.
Accepts multipart/form-data or JSON. The token may only be used once; a second attempt is
409 token_already_used. Bodies are capped at 4.5 MB in a hosted deployment, so prefer the
two-step upload above for anything larger.
| Field | Where | Meaning |
|---|---|---|
session | form field / JSON | The client_token. |
file | form field | The PDF. Multipart form only. |
payload | JSON | Data URI or bare base64, as an alternative to file. |
pages | form field / JSON | Page count, for analytics. |
curl -X POST https://api.instantscan.io/v1/scanner/upload \
-F "session=eyJzaWQiOiJzc185UXcxcDJLdCI…" \
-F "pages=3" \
-F "file=@scan.pdf;type=application/pdf"
{
"status": "ok",
"session_id": "ss_9Qw1p2Kt",
"result": {
"url": "https://api.instantscan.io/v1/files/res_7Xk3?exp=1735776000000&sig=9c21…",
"page_count": 3,
"bytes": 412778,
"content_type": "application/pdf"
}
}
Side effects: the session becomes completed, a usage event is recorded, and a signed
scan.completed webhook is dispatched.
Errors: 400 invalid_token, 400 empty_file, 413 file_too_large, 410 session_expired,
409 token_already_used.
GET /v1/files/:id
Download a result. Auth: the URL signature itself — no header, no cookie.
| Query | Meaning |
|---|---|
exp | Expiry, epoch milliseconds. |
sig | HMAC over the id and expiry. |
Never construct this URL yourself. Use the one from the webhook, the session or the upload response.
curl -o scan.pdf \
"https://api.instantscan.io/v1/files/res_7Xk3?exp=1735776000000&sig=9c21…"
Responds 200 with Content-Type: application/pdf and the file bytes, or 302 to a short-lived
storage URL when documents are held outside the API — which is the case in a hosted deployment,
because a response body through the API is capped at 4.5 MB. Follow redirects: curl -L, and every
HTTP client’s default.
Tampering with id, exp or sig gives 403 invalid_signature; an expired or dropped file gives
404 not_found. Files are retained for 24 hours by default.
Console API
Everything under /v1/dashboard/* requires a console session cookie. These are the endpoints the
dashboard uses; they are documented because you may want to script account setup, not because you need
them for a normal integration.
Without a valid cookie every one of them responds 401.
POST /v1/auth/signup
Create an account. No authentication.
Atomically creates the tenant, the user, a publishable and a secret test key, and a starter credit grant of 250. Sets the session cookie, so signup logs you in.
| Body field | Type | Required |
|---|---|---|
email | string | yes |
password | string | yes — at least 10 characters |
name | string | no |
company | string | no — becomes the tenant name |
curl -X POST https://api.instantscan.io/v1/auth/signup \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{ "email": "dana@acme.example", "password": "correct-horse-battery", "company": "Acme Lending" }'
201 Created
{
"user": { "id": "usr_4Kd", "email": "dana@acme.example", "name": "dana" },
"tenant": {
"id": "tn_acme",
"name": "Acme Lending",
"plan": "free",
"credits": 250,
"allowed_origins": []
}
}
Errors: 400 invalid_request with fields, 409 email_taken.
POST /v1/auth/login
Exchange credentials for a session cookie. No authentication.
curl -X POST https://api.instantscan.io/v1/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{ "email": "demo@usecollect.com", "password": "scanner-demo" }'
{
"user": {
"id": "usr_4Kd",
"email": "demo@usecollect.com",
"name": "Dana Okafor",
"theme_mode": "auto"
},
"tenant": {
"id": "tn_acme",
"name": "Acme Lending",
"plan": "starter",
"credits": 2483,
"allowed_origins": ["http://localhost:5173"]
}
}
PUT /v1/dashboard/settings
Save the current user’s console appearance. Auth: console session.
curl -X PUT https://api.instantscan.io/v1/dashboard/settings \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "theme_mode": "light" }'
theme_mode is light, dark, or auto. The response is the same shape as
GET /v1/auth/me. Invalid values return 400 invalid_request.
Sets sc_session as httpOnly; SameSite=Lax. Passwords are hashed with scrypt and a per-user salt,
and never appear in any response.
Wrong password and unknown email both return the same 401 invalid_credentials, so the endpoint is not
an account-enumeration oracle.
POST /v1/auth/logout
Destroy the server-side session and clear the cookie. Safe to call without a session.
curl -X POST https://api.instantscan.io/v1/auth/logout -b cookies.txt
{ "status": "ok" }
GET /v1/auth/me
The current user and tenant, including the live credit balance. Auth: console session.
curl https://api.instantscan.io/v1/auth/me -b cookies.txt
{
"user": { "id": "usr_4Kd", "email": "demo@usecollect.com", "name": "Dana Okafor" },
"tenant": {
"id": "tn_acme",
"name": "Acme Lending",
"plan": "starter",
"credits": 2483,
"allowed_origins": ["http://localhost:5173", "http://localhost:8080"]
}
}
GET /v1/dashboard/keys
List every key, including revoked ones. Auth: console session.
Secret key values are irreversibly masked. Only the display field is available, and it is not
recoverable — for existing keys, not even by us.
curl https://api.instantscan.io/v1/dashboard/keys -b cookies.txt
{
"keys": [
{
"id": "key_2Rt",
"name": "Docs example key",
"kind": "publishable",
"mode": "test",
"display": "pk_test_demo",
"created_at": "2025-12-02T09:00:00.000Z",
"last_used_at": "2026-01-01T08:41:12.000Z",
"revoked_at": null
},
{
"id": "key_9Wq",
"name": "Production",
"kind": "secret",
"mode": "live",
"display": "sk_live_••••••••4f2a",
"created_at": "2025-12-02T09:00:00.000Z",
"last_used_at": null,
"revoked_at": null
}
]
}
POST /v1/dashboard/keys
Create a key. Auth: console session.
The full secret is returned exactly once, in this response. Afterwards only the hash exists.
| Body field | Type | Required | Values |
|---|---|---|---|
name | string | no | 60 characters or fewer |
kind | string | yes | publishable, secret |
mode | string | yes | test, live |
curl -X POST https://api.instantscan.io/v1/dashboard/keys \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "name": "Checkout flow", "kind": "secret", "mode": "live" }'
201 Created
{
"key": {
"id": "key_7Lm",
"name": "Checkout flow",
"kind": "secret",
"mode": "live",
"display": "sk_live_••••••••b71c",
"created_at": "2026-01-01T09:20:00.000Z",
"last_used_at": null,
"revoked_at": null
},
"secret": "sk_live_9f4d2ac1e8b7550aa3c6de41b71c"
}
Errors: 400 invalid_request with fields.
DELETE /v1/dashboard/keys/:id
Revoke a key. Auth: console session. Idempotent — revoking twice is not an error.
The key stays in the list with revoked_at set, so your history is not rewritten. Any later use of the
value gets 401 invalid_api_key.
curl -X DELETE https://api.instantscan.io/v1/dashboard/keys/key_7Lm -b cookies.txt
{
"key": {
"id": "key_7Lm",
"name": "Checkout flow",
"kind": "secret",
"mode": "live",
"display": "sk_live_••••••••b71c",
"created_at": "2026-01-01T09:20:00.000Z",
"last_used_at": null,
"revoked_at": "2026-01-01T09:41:00.000Z"
}
}
Another tenant’s key id returns 404 not_found.
GET /v1/dashboard/domains
The origin allowlist for your publishable keys. Auth: console session.
curl https://api.instantscan.io/v1/dashboard/domains -b cookies.txt
{ "domains": ["http://localhost:5173", "https://acme.example"] }
POST /v1/dashboard/domains
Add an origin. Auth: console session.
Input is normalised, so acme.example, https://acme.example and
https://acme.example/apply?step=2 all store https://acme.example. A bare hostname is assumed to be
https.
| Body field | Type | Required |
|---|---|---|
domain | string | yes — a hostname or an http(s) URL |
curl -X POST https://api.instantscan.io/v1/dashboard/domains \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "domain": "acme.example" }'
201 Created
{ "domains": ["http://localhost:5173", "https://acme.example"] }
Errors: 400 invalid_request for an unparseable value or a duplicate. The list is not modified on
error. There are no wildcard subdomains — add each origin.
POST /v1/dashboard/domains/remove
Remove an origin. Auth: console session. A POST rather than a DELETE because the identifier is a
URL and does not survive being a path segment.
curl -X POST https://api.instantscan.io/v1/dashboard/domains/remove \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "domain": "https://acme.example" }'
{ "domains": ["http://localhost:5173"] }
Errors: 404 not_found when the normalised origin is not on the list.
GET /v1/dashboard/credits
Balance, 30-day spend and the ledger (most recent first, up to 200 entries). Auth: console session.
curl https://api.instantscan.io/v1/dashboard/credits -b cookies.txt
{
"balance": 2483,
"spent_30d": 267,
"ledger": [
{
"id": "ctx_8Kd2p",
"delta": -1,
"balance_after": 2483,
"reason": "session",
"session_id": "ss_9Qw1p2Kt",
"note": null,
"created_at": "2026-01-01T09:12:44.000Z"
}
]
}
reason is one of signup_grant, topup, session, adjustment. The ledger is append-only, and the
sum of its deltas equals balance.
POST /v1/dashboard/credits/topup
Add credits. Auth: console session.
| Body field | Type | Required | Values |
|---|---|---|---|
amount | number | yes | 500, 2500, 10000, 50000 |
curl -X POST https://api.instantscan.io/v1/dashboard/credits/topup \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "amount": 2500 }'
201 Created — same shape as GET /v1/dashboard/credits, with the new balance and a fresh topup
entry at the head of the ledger.
Errors: 400 invalid_request for any amount outside the packs. This endpoint stands in for a checkout;
it does not charge a card.
GET /v1/dashboard/analytics
Aggregated usage for a window. Auth: console session.
| Query | Type | Default | Notes |
|---|---|---|---|
days | number | 30 | 1–90. Absent, out of range or unparseable falls back to 30 rather than erroring. |
curl "https://api.instantscan.io/v1/dashboard/analytics?days=30" -b cookies.txt
{
"from": "2025-12-02",
"to": "2026-01-01",
"days": 30,
"totals": {
"sessions": 312,
"completed": 268,
"failed": 9,
"expired": 27,
"pages": 641,
"bytes": 187443210,
"credits_spent": 312,
"completion_rate": 0.859
},
"series": [
{ "date": "2025-12-02", "created": 11, "completed": 9, "failed": 0 },
{ "date": "2025-12-03", "created": 0, "completed": 0, "failed": 0 }
],
"by_status": [
{ "status": "delivered", "count": 254 },
{ "status": "expired", "count": 27 }
],
"by_mode": [
{ "mode": "test", "count": 45 },
{ "mode": "live", "count": 267 }
],
"recent": [
{
"id": "ss_9Qw1p2Kt",
"status": "delivered",
"mode": "live",
"pages": 3,
"created_at": "2026-01-01T09:12:44.000Z"
}
]
}
series always has exactly days buckets, including zero-activity days. completion_rate is
completed over created, and 0 when there were no sessions. by_mode always contains both test
and live, zeros included, in that order.
GET /v1/dashboard/sessions
Recent sessions, newest first. Auth: console session.
| Query | Type | Default | Max |
|---|---|---|---|
limit | number | 50 | 200 |
curl "https://api.instantscan.io/v1/dashboard/sessions?limit=50" -b cookies.txt
{
"sessions": [
{
"id": "ss_9Qw1p2Kt",
"status": "delivered",
"mode": "live",
"pages": 3,
"metadata": { "application_id": "app_1042" },
"created_at": "2026-01-01T09:12:44.000Z",
"expires_at": "2026-01-01T09:27:44.000Z"
}
]
}
GET /v1/dashboard/scanner
Your named scanner profiles, default profile id, compatibility view of the default scanner, and platform defaults. Auth: console session.
curl https://api.instantscan.io/v1/dashboard/scanner -b cookies.txt
{
"version": 1,
"default_profile_id": "scp_default",
"profiles": [
{
"id": "scp_default",
"name": "Default",
"scanner": { "locale": "en", "...": "…" }
}
],
"scanner": {
"locale": "en",
"theme": { "accent": "#1a90cc", "cornerRadius": 12, "brandName": "Acme Lending" },
"completion": { "message": "Document received." },
"options": {
"maxPages": 12,
"output": "pdf",
"grayscale": false,
"showFlash": true,
"showZoom": true,
"showA4Hint": true
},
"watermark": { "enabled": false, "color": "#334155", "opacity": 0.18, "size": "md" }
},
"defaults": {
"locale": "en",
"theme": { "accent": "#1a90cc", "cornerRadius": 10 },
"completion": {},
"options": {
"maxPages": 20,
"output": "pdf",
"grayscale": false,
"showFlash": true,
"showZoom": true,
"showA4Hint": true
},
"watermark": { "enabled": false, "color": "#334155", "opacity": 0.18, "size": "md" }
}
}
PUT /v1/dashboard/scanner
Save customization. Auth: console session.
A partial body is a patch: fields you omit keep their saved values. Every field is validated, and nothing is persisted if any field fails. See Customization for each field’s rules.
curl -X PUT https://api.instantscan.io/v1/dashboard/scanner \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"locale": "fr",
"theme": { "accent": "#7c3aed", "brandName": "Acme" },
"options": { "maxPages": 5, "grayscale": true },
"watermark": { "enabled": true, "text": "Acme · Secure scan" }
}'
{
"scanner": {
"locale": "fr",
"theme": { "accent": "#7c3aed", "cornerRadius": 12, "brandName": "Acme" },
"completion": { "message": "Document received." },
"options": {
"maxPages": 5,
"output": "pdf",
"grayscale": true,
"showFlash": true,
"showZoom": true,
"showA4Hint": true
},
"watermark": {
"enabled": true,
"text": "Acme · Secure scan",
"color": "#334155",
"opacity": 0.18,
"size": "md"
}
}
}
Errors: 400 invalid_request with a fields map keyed by path, for example theme.accent,
options.maxPages or watermark.text.
This compatibility endpoint updates the current default profile. New integrations should use the profile routes below.
POST /v1/dashboard/scanner/profiles
Create a named scanner profile. Auth: console session.
curl -X POST https://api.instantscan.io/v1/dashboard/scanner/profiles \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "name": "French", "scanner": { "locale": "fr" } }'
Names must be unique per tenant. scanner is optional and starts from platform defaults. Returns
201 with { "profile": { "id": "scp_…", "name": "French", "scanner": { "…": "…" } } }.
PUT /v1/dashboard/scanner/profiles/:id
Rename or update one profile, or make it the default. Auth: console session.
{
"name": "French applications",
"scanner": { "options": { "maxPages": 2 } },
"make_default": true
}
The scanner body is a patch over that profile. Returns the updated profile and
default_profile_id; unknown ids return 404.
DELETE /v1/dashboard/scanner/profiles/:id
Delete a non-default profile. Auth: console session. Existing sessions keep their resolved
scanner snapshot. The current default returns 409 default_profile until another profile is made
default; unknown ids return 404.
POST /v1/dashboard/scanner/preview-session
Open a throwaway session that uses a saved profile. Auth: console session.
This is what the console’s “Test in a real session” button calls. Pass { "scanner_id": "scp_…" };
omitting it selects the default. It is always test mode so it shows up against staging traffic, and
like any session it is free to open — a credit is spent only if you actually scan a document.
It returns the same payload as POST /v1/sessions. Use it to check your
settings against the real engine rather than against a rendering of it.
curl -X POST https://api.instantscan.io/v1/dashboard/scanner/preview-session -b cookies.txt
{
"id": "ss_4Kd8m1Zx",
"mode": "test",
"client_token": "eyJzaWQiOiJzc180S2Q4bTFaeCIsInRpZCI6InRuX2FjbWUiLCJpYXQiOjE3MzU2ODk2MDAwMDB9.7b2e…",
"scan_url": "https://scan.instantscan.io/?t=eyJzaWQiOiJzc180S2Q4bTFaeCIsInRpZCI6InRuX2FjbWUi…",
"expires_at": "2026-01-01T00:15:00.000Z"
}
The session carries metadata.source = "dashboard_preview", so you can filter these out of your
own reporting.
Errors: 402 insufficient_credits.
GET /v1/dashboard/webhook
Your webhook configuration. Auth: console session.
curl "https://api.instantscan.io/v1/dashboard/webhook" -b cookies.txt
{
"url": "https://acme.example/webhooks/scanner",
"events": ["scan.completed"],
"secret": "whsec_••••••••cret"
}
secret is always masked here. The full value is shown exactly once — when you create or
rotate it — mirroring how a secret API key is only ever
displayed at creation. Store it then; if you lose it, rotate for a new one.
PUT /v1/dashboard/webhook
Set the endpoint and event subscription. Auth: console session.
| Body field | Type | Notes |
|---|---|---|
url | string | Must be https, except on localhost. An empty string clears it. |
events | string[] | scan.completed, scan.failed, test.ping. Empty defaults to ["scan.completed"]. |
curl -X PUT https://api.instantscan.io/v1/dashboard/webhook \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{ "url": "https://acme.example/webhooks/scanner", "events": ["scan.completed", "scan.failed"] }'
{
"url": "https://acme.example/webhooks/scanner",
"events": ["scan.completed", "scan.failed"],
"secret": "whsec_••••••••cret"
}
Errors: 400 invalid_request with fields.url or fields.events.
POST /v1/dashboard/webhook/rotate-secret
Generate a new signing secret. Auth: console session.
Returned in full exactly once. Rotation affects future deliveries only — retries already in flight keep the secret they were signed with.
curl -X POST https://api.instantscan.io/v1/dashboard/webhook/rotate-secret -b cookies.txt
201 Created
{ "secret": "whsec_2f9c41ab7d5e08c3a6b1" }
POST /v1/dashboard/webhook/test
Deliver a synthetic test.ping to your endpoint, without scanning anything. Auth: console session.
curl -X POST https://api.instantscan.io/v1/dashboard/webhook/test -b cookies.txt
201 Created
{
"delivery": {
"id": "whd_3Kp",
"session_id": "ss_test_ping",
"event": "test.ping",
"url": "https://acme.example/webhooks/scanner",
"status": "succeeded",
"attempts": [
{ "at": "2026-01-01T09:44:02.000Z", "ms": 118, "response_status": 204, "error": null }
],
"payload": "{\"type\":\"test.ping\",\"session_id\":\"ss_test_ping\",\"metadata\":{\"source\":\"dashboard\"},\"result\":null,\"created_at\":\"2026-01-01T09:44:02.000Z\"}",
"created_at": "2026-01-01T09:44:02.000Z",
"replay_of": null
}
}
Errors: 400 no_endpoint when no endpoint is configured.
GET /v1/dashboard/webhook/deliveries
The delivery log, newest first. Auth: console session.
| Query | Type | Default | Max |
|---|---|---|---|
limit | number | 50 | 200 |
curl "https://api.instantscan.io/v1/dashboard/webhook/deliveries?limit=50" -b cookies.txt
{
"deliveries": [
{
"id": "whd_5Rq",
"session_id": "ss_9Qw1p2Kt",
"event": "scan.completed",
"url": "https://acme.example/webhooks/scanner",
"status": "failed",
"attempts": [
{
"at": "2026-01-01T09:12:45.000Z",
"ms": 3004,
"response_status": null,
"error": "timeout"
},
{ "at": "2026-01-01T09:12:50.000Z", "ms": 214, "response_status": 500, "error": null },
{ "at": "2026-01-01T09:13:02.000Z", "ms": 198, "response_status": 500, "error": null }
],
"payload": "{\"type\":\"scan.completed\",\"session_id\":\"ss_9Qw1p2Kt\", … }",
"created_at": "2026-01-01T09:12:45.000Z",
"replay_of": null
}
]
}
payload is exactly the bytes that were signed, so you can reproduce the signature locally when
debugging. status is pending, succeeded or failed.
POST /v1/dashboard/webhook/deliveries/:id/replay
Re-send a past delivery. Auth: console session.
Creates a new delivery whose replay_of points at the original. The original is never modified.
curl -X POST https://api.instantscan.io/v1/dashboard/webhook/deliveries/whd_5Rq/replay \
-b cookies.txt
201 Created
{
"delivery": {
"id": "whd_8Zt",
"session_id": "ss_9Qw1p2Kt",
"event": "scan.completed",
"url": "https://acme.example/webhooks/scanner",
"status": "succeeded",
"attempts": [
{ "at": "2026-01-01T10:02:11.000Z", "ms": 142, "response_status": 204, "error": null }
],
"payload": "{\"type\":\"scan.completed\",\"session_id\":\"ss_9Qw1p2Kt\", … }",
"created_at": "2026-01-01T10:02:11.000Z",
"replay_of": "whd_5Rq"
}
}
Errors: 404 not_found for an unknown id or another tenant’s delivery.
Scheduled work
POST /v1/cron/webhooks
Retry every webhook delivery that is due. Auth: Authorization: Bearer $CRON_SECRET.
This is an operator endpoint, not part of an integration — it is listed so that a self-hosted deployment knows it has to be scheduled. Retries are recorded as rows rather than held as in-process timers, so something has to come and run them; without this on a schedule, a delivery that fails its first attempt is never retried.
Run it about every 30 seconds. It is safe to call concurrently and safe to call when nothing is due:
each run claims only what it takes, and overlapping runs do not attempt the same delivery twice.
On the hosted deployment an empty run does not open the database — it reads an out-of-band flag
and returns { skipped: true } so the schedule cannot keep Postgres awake (ADR-010).
curl -X POST https://api.instantscan.io/v1/cron/webhooks \
-H "Authorization: Bearer $CRON_SECRET"
{ "status": "ok", "attempted": 3, "skipped": false }
Errors: 401 unauthorized for a wrong secret — and for any request at all when CRON_SECRET is
unset, since an open endpoint here would let a stranger make the platform send signed requests to
tenant endpoints.
Rate limits
Session creation is limited per tenant: 60 requests per 60 seconds by default. Exceeding it returns
429 rate_limited; retry with exponential backoff.
The window is a fixed one held in shared storage, so the limit is global rather than per instance. Because windows are fixed rather than sliding, a burst straddling a boundary can briefly land up to twice the limit.
Conventions
- Reading another tenant’s object is a
404, never a403. A403would confirm the id exists. - Secrets are returned exactly once, at creation. Key values and webhook secrets are stored as hashes or shown masked.
- Validation reports every problem at once in
fields, so a form needs one round trip. - Nothing partial is persisted. A rejected write leaves the previous state untouched.