Webhooks
A webhook calls your URL on every recorded scan. Add one from a code's page. The scanner is redirected right away and delivery happens in the background.
What you receive
By default a POST with this JSON body:
{
"event": "qr.scanned",
"timestamp": 1793548800000,
"qrCode": {
"id": "c1",
"name": "Spring flyer",
"type": "url",
"shortCode": "abc123"
},
"scan": {
"id": "s1",
"ipAddress": "203.0.113.7",
"userAgent": "Mozilla/5.0 ...",
"referer": null,
"scannedAt": "2026-11-01T12:00:00.000Z",
"country": "US",
"city": "Portland",
"region": "OR",
"latitude": "45.52",
"longitude": "-122.68",
"device": "iPhone",
"deviceType": "mobile",
"browser": "Safari",
"os": "iOS",
"utmSource": "flyer",
"utmMedium": null,
"utmCampaign": null,
"utmTerm": null,
"utmContent": null,
"referrerCategory": "direct",
"isUniqueVisitor": true
}
}
A body template replaces this. {{qrCode.name}} and {{scan.city}} fill in from the fields above, and {{secret.KEY}} fills in a secret from Settings > Secrets, so it doesn't sit in the webhook config.
A field with no value becomes empty text, or null when the placeholder isn't inside quotes.
Post to Slack or Discord
Paste an incoming webhook URL, then press Slack or Discord above the body template. It fills in a message with the code's name, device type and country, which you can edit.
Verify a delivery
Each delivery has X-QR-Signature, a hex HMAC-SHA256 of the raw request body keyed with the webhook's secret. A GET has no body, so it signs the target URL.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidDelivery(rawBody: string, signature: string, secret: string) {
const expected = createHmac("sha256", secret).update(rawBody).digest();
const given = Buffer.from(signature, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
X-QR-Timestamp is the scan time in milliseconds. It isn't part of the signature, so this doesn't stop a replay. Dedupe on scan.id if that matters.
When your endpoint fails
Delivery goes through QStash, which retries 3 times over about 15 minutes. After that the delivery is marked failed, the workspace gets a notification, and members who opted in get an email. Settings > Webhooks lists deliveries and has a retry button.
Retries mean your endpoint can see the same scan twice, so dedupe on scan.id.
Targets that are refused
URLs pointing at private or internal addresses are rejected when you save the webhook and again at delivery, including through DNS and redirects.