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.