Webhooks
Webhooks
Webhooks deliver real-time notifications when events occur in your workspace — for example when a QR code is scanned.
Create a Webhook
POST /v1/webhooks{ "url": "https://example.com/webhooks/qr3", "events": ["qr.scanned", "qr.created"], "secret": "my-secret-key-min-16-chars"}Response
{ "id": "wh_abc123", "url": "https://example.com/webhooks/qr3", "events": ["qr.scanned", "qr.created"], "is_active": true, "secret": "my-secret-key-min-16-chars", "secret_hint": "my-s…", "created_at": "2026-03-15T10:00:00.000Z"}Event Types
| Event | Description |
|---|---|
* | All events |
qr.created | QR code created |
qr.updated | QR code updated (URL, status, tags) |
qr.deleted | QR code deleted |
qr.scanned | QR code scanned |
qr.flagged | QR code flagged as unsafe |
webhook.ping | Test ping (via the /ping endpoint) |
Payload Format
All webhook payloads have this format:
{ "id": "evt_4f0c2a91d7e84b3c9a1f", "type": "qr.scanned", "created": "2026-03-15T10:00:00.000Z", "data": { "code_id": "qr_abc123", "short_code": "r7f3Kx", "scanned_at": "2026-03-15T10:00:00.000Z", "country": "AT", "device_type": "mobile", "os": "iOS", "browser": "Safari", "language": "de", "ab_variant": null, "redirected_to": "https://example.com/landing" }}Code events: qr.created and qr.updated
For these two events, data is the code exactly as GET /v1/codes/:id returns it. That includes appearance, the colors stored on the code (null means the standard black or white):
{ "id": "evt_9b2e41c07a5d4f18b3c6", "type": "qr.updated", "created": "2026-09-28T10:00:00.000Z", "data": { "id": "qr_abc123", "short_code": "r7f3Kx", "status": "live", "appearance": { "foreground_color": "#1F4E79", "background_color": null }, "updated_at": "2026-09-28T10:00:00.000Z" }}The example is shortened. The full list of fields is in the Codes API.
Signature Verification
qr3.app signs every webhook request with HMAC-SHA256:
X-QR3-Signature: sha256=a1b2c3d4e5f6...X-QR3-Event: qr.scannedX-QR3-Delivery: wdl_abc123X-QR3-Attempt: 1User-Agent: qr3-webhooks/1.0Implementing verification
import crypto from "crypto";
function verifySignature( payload: string, signature: string | undefined, secret: string): boolean { const expected = crypto .createHmac("sha256", secret) .update(payload) .digest("hex"); const expectedBytes = Buffer.from(`sha256=${expected}`); const receivedBytes = Buffer.from(signature ?? ""); return ( expectedBytes.length === receivedBytes.length && crypto.timingSafeEqual(expectedBytes, receivedBytes) );}
// In Express: verify the raw body — re-serialised JSON breaks the signature.app.post( "/webhooks/qr3", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); const signature = req.headers["x-qr3-signature"] as string | undefined; if (!verifySignature(raw, signature, process.env.QR3_WEBHOOK_SECRET!)) { return res.status(401).json({ error: "Invalid signature" }); } const event = JSON.parse(raw); console.log(event.type, event.data); res.json({ received: true }); },);import hmacimport hashlib
def verify_signature(payload: str, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload.encode(), hashlib.sha256 ).hexdigest() received = (signature or "").removeprefix("sha256=") return hmac.compare_digest(expected, received)Retry Logic
On failure (HTTP >= 300 or timeout) qr3.app retries the delivery:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
After 10 consecutive failures the webhook is deactivated automatically.
Delivery Guarantee
qr3.app delivers at least once, not exactly once. The same event can therefore reach your endpoint more than once: as a retry after an error, or as a repeat of an attempt that has already been sent.
- Deduplicate with the
idof the payload (evt_…). It belongs to the event and stays the same for every attempt and every repeat.X-QR3-Deliverynames the single attempt: a repeat of that attempt carries the same value, a new attempt a new one. Acknowledge anidyou already know with a 2xx status, not a 4xx: a 4xx counts as a failed attempt, and after 10 consecutive failures the webhook is deactivated automatically. - An attempt that is still
pendingone hour after it was created (for example because its queue message was lost) is set tofailedby a daily cleanup;status_codeandresponse_time_msstaynull. This is not a failure of your endpoint and does not count towards the 10 consecutive failures. The log stores the textDelivery was not completed in time — closed by cleanupfor such an attempt; the delivery log endpoint does not return it. Closing is not final: if the message still arrives, it is delivered and its real result replaces that text.
Delivery Logs
GET /v1/webhooks/:id/deliveries{ "data": [ { "id": "wdl_abc123", "event_type": "qr.scanned", "status": "success", "status_code": 200, "response_time_ms": 145, "attempt": 1, "created_at": "2026-03-15T10:00:00.000Z" } ]}Delivery attempts are kept for 90 days (the current UTC day plus the 89 preceding days) and then deleted automatically. Besides the fields shown, the log stores the event payload and the first 1 KB of the endpoint’s response; this endpoint returns neither of them.
Test Ping
Test a webhook immediately:
POST /v1/webhooks/:id/pingThis sends a webhook.ping event to your endpoint URL.