Webhooks are the only delivery mechanism that works with no browser involved, which makes them the one to build against. The postMessage and polling paths are conveniences layered on the same event.
Configure the endpoint
In the console, open Webhooks and set the endpoint URL and which events you want. Via the API:
curl -X PUT https://api.instantscan.io/v1/dashboard/webhook \
-H "Content-Type: application/json" \
-b "sc_session=<console session cookie>" \
-d '{ "url": "https://acme.example/webhooks/scanner", "events": ["scan.completed", "scan.failed"] }'
The URL must be https, with one exception: localhost may use http, so you can develop against a
tunnel or a local server.
A single session can override the destination with callback_url at creation time. That is useful for
routing a specific integration elsewhere; the tenant endpoint remains the default for everything
else.
Events
| Event | When |
|---|---|
scan.completed | A document was uploaded and stored. Carries the result. |
scan.failed | The scan or the upload failed. result is null. |
test.ping | You pressed Send test event. Never emitted by real traffic. |
Payload
{
"type": "scan.completed",
"session_id": "ss_9Qw1p2Kt",
"metadata": { "application_id": "app_1042" },
"result": {
"url": "https://api.instantscan.io/v1/files/res_7Xk3?exp=1735693200000&sig=1f8c…",
"page_count": 3,
"bytes": 412778,
"content_type": "application/pdf"
},
"created_at": "2026-01-01T00:00:00.000Z"
}
metadata is whatever you passed when creating the session, unchanged. It is how you find the row
this scan belongs to without keeping a session-id lookup table.
result.url is signed and short-lived. Download it inside the handler and store the bytes
yourself — do not save the URL and fetch it tomorrow.
Verifying a signature
Every request carries:
X-Scanner-Signature: t=1735689600,v1=8f2b1d0c…
v1 is HMAC-SHA256(secret, "<t>.<raw request body>"), hex encoded. Two rules matter: verify against
the raw bytes, and reject old timestamps. A valid signature over a replayed body is still a
replay.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(
header.split(",").map((piece) => {
const [k, v] = piece.split("=");
return [k?.trim() ?? "", v?.trim() ?? ""] as const;
})
);
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
// Length check first: timingSafeEqual throws on a mismatch.
return a.length === b.length && timingSafeEqual(a, b);
}
Express
app.post("/webhooks/scanner", express.raw({ type: "*/*" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Scanner-Signature") ?? "", process.env.SCANNER_WEBHOOK_SECRET!)) {
return res.sendStatus(400);
}
const event = JSON.parse(raw);
void handle(event); // do the work after answering
res.sendStatus(204);
});
express.json() will not work here. It parses and discards the raw bytes, and a re-serialised body
produces a different signature.
Hono / Cloudflare Workers
app.post("/webhooks/scanner", async (c) => {
const raw = await c.req.text();
if (!verify(raw, c.req.header("X-Scanner-Signature") ?? "", c.env.SCANNER_WEBHOOK_SECRET)) {
return c.text("bad signature", 400);
}
c.executionCtx.waitUntil(handle(JSON.parse(raw)));
return c.body(null, 204);
});
Responding
Answer 2xx and answer fast. Anything else is a failure and will be retried.
- Do the work after responding, or hand it to a queue. Our delivery timeout is short, and a handler that downloads a 4 MB PDF before responding will time out under load.
- Make your handler idempotent, keyed on
session_id. Retries and manual replays both mean you can see the same event twice.
Retries
A delivery is attempted up to three times with exponential backoff. Every attempt is recorded with its response status, duration and, on a transport failure, the error string.
A delivery is succeeded on the first 2xx, failed once the attempts are exhausted, and pending
while retries are outstanding.
The delivery log
Webhooks → Deliveries in the console shows, for every delivery: the event, target URL, status, attempt count, response status per attempt, duration, and the exact request body that was signed.
Two actions from that table:
- Replay re-sends a past delivery. It creates a new delivery record referencing the original
through
replay_of; the original is never modified, so your history stays truthful. - Send test event delivers a
test.pingwithout a scan. This is the fastest way to confirm your endpoint and signature check work at all.
curl -X POST https://api.instantscan.io/v1/dashboard/webhook/deliveries/whd_3Kp/replay \
-b "sc_session=<console session cookie>"
Rotating the signing secret
curl -X POST https://api.instantscan.io/v1/dashboard/webhook/rotate-secret \
-b "sc_session=<console session cookie>"
The new secret is returned in full exactly once. Rotation applies to future deliveries only — in-flight retries keep the secret they were signed with — so the safe sequence is: accept both secrets in your handler, rotate, deploy with only the new one.
Local development
The endpoint must be reachable from our API, so a tunnel is the usual answer:
# any tunnel works; ngrok shown for familiarity
ngrok http 4000
# then set the endpoint to https://<subdomain>.ngrok.app/webhooks/scanner
If you are running the whole stack locally, http://localhost:4000/... is accepted directly.
Failure modes worth planning for
| Symptom | Likely cause |
|---|---|
| Signature never matches | The body was parsed and re-serialised before verification. |
| Signature matches locally, fails in production | A proxy is rewriting the body, or the secret was rotated and not deployed. |
Deliveries show 500 from your side | Your handler is doing the work before responding, and timing out. |
| The same document processed twice | No idempotency key. Deduplicate on session_id. |
| No delivery at all | The event is not in your subscription list, or the endpoint is not https. |