Skip to content
instantscan.io docs

Webhooks

The canonical way to receive a scan. Event payloads, signature verification, retry behaviour, the delivery log, replay, and secret rotation.

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

EventWhen
scan.completedA document was uploaded and stored. Carries the result.
scan.failedThe scan or the upload failed. result is null.
test.pingYou 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.ping without 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

SymptomLikely cause
Signature never matchesThe body was parsed and re-serialised before verification.
Signature matches locally, fails in productionA proxy is rewriting the body, or the secret was rotated and not deployed.
Deliveries show 500 from your sideYour handler is doing the work before responding, and timing out.
The same document processed twiceNo idempotency key. Deduplicate on session_id.
No delivery at allThe event is not in your subscription list, or the endpoint is not https.