Skip to content
instantscan.io docs

API reference

Every endpoint the /v1 API exposes — method, path, authentication, parameters and an example response — for both the public scanning API and the console API.

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.

CredentialHeaderCan do
Publishable key pk_…Authorization: Bearer pk_test_demoCreate a session, and only from an allowlisted Origin. Nothing else.
Secret key sk_…Authorization: Bearer sk_test_demoCreate a session, read a session and its result. Server-side only.
Console sessionCookie: 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 fieldTypeRequiredMeaning
metadataobjectnoOpaque JSON echoed back in the session, the webhook and analytics.
scanner_idstringnoNamed scanner profile to use. Defaults to the account’s default profile.
scannerobjectnoPer-session customization, deep-merged over the selected profile. See Customization.
allowed_originsstring[]noOrigins permitted to embed this session and receive its result. Defaults to your account allowlist.
callback_urlstringnoWebhook 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.

QueryTypeRequired
tstringThe session’s client_token.
cstringA 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 fieldTypeMeaning
sessionstringThe client_token.
pagesnumberPage 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.

FieldWhereMeaning
sessionform field / JSONThe client_token.
fileform fieldThe PDF. Multipart form only.
payloadJSONData URI or bare base64, as an alternative to file.
pagesform field / JSONPage 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.

QueryMeaning
expExpiry, epoch milliseconds.
sigHMAC 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 fieldTypeRequired
emailstringyes
passwordstringyes — at least 10 characters
namestringno
companystringno — 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 fieldTypeRequiredValues
namestringno60 characters or fewer
kindstringyespublishable, secret
modestringyestest, 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 fieldTypeRequired
domainstringyes — 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 fieldTypeRequiredValues
amountnumberyes500, 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.

QueryTypeDefaultNotes
daysnumber301–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.

QueryTypeDefaultMax
limitnumber50200
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 fieldTypeNotes
urlstringMust be https, except on localhost. An empty string clears it.
eventsstring[]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.

QueryTypeDefaultMax
limitnumber50200
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 a 403. A 403 would 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.