Skip to content

Concepts

Webhooks

On every scan we POST a scan.created event to your endpoints. Available on Pro and Business. Add endpoints in the dashboard under API; each endpoint gets its own signing secret (whsec_…), shown once when you add it.

The event#

# POST to your endpoint

Headers:
  Content-Type: application/json
  X-Webhook-Event: scan.created
  X-Webhook-Signature: sha256=<hex HMAC-SHA256 of the raw body>

{
  "id": "d3f1…",              // stable per event; dedupe on it
  "event": "scan.created",
  "created_at": "2026-07-19T14:58:09.915Z",
  "data": {
    "qr_id": "4a38…", "slug": "aB3xY9k",
    "destination": "https://example.com/manual",
    "country": "DE", "city": "Berlin",
    "os": "iOS", "device": "mobile",
    "scanned_at": "2026-07-19T14:58:09.812Z"
  }
}

Scan data contains no personal information: no IP, no exact location, no fingerprint.

Verify the signature#

Compute HMAC-SHA256 of the raw request body with the endpoint's secret and compare against the header. Use a constant-time comparison and the raw bytes, not the re-serialized JSON:

# Node (Express)

import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/hooks/qr", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.get("X-Webhook-Signature") ?? "";
  const expected =
    "sha256=" +
    createHmac("sha256", process.env.HOOK_SECRET).update(req.body).digest("hex");
  const ok =
    sig.length === expected.length &&
    timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body);
  // …handle after responding; dedupe on event.id
  res.status(200).end();
});

Delivery and retries#

  • Answer with a 2xx within 4 seconds; do slow work after responding.
  • Failed deliveries retry with backoff. Retries reuse the same event id, so dedupe on it; only the endpoints that failed are retried.
  • The dashboard shows per-endpoint delivery and failure counts.