Skip to content

Webhooks

Webhooks

Webhooks deliver real-time notifications when events occur in your workspace — for example when a QR code is scanned.

Create a Webhook

Terminal window
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

EventDescription
*All events
qr.createdQR code created
qr.updatedQR code updated (URL, status, tags)
qr.deletedQR code deleted
qr.scannedQR code scanned
qr.flaggedQR code flagged as unsafe
webhook.pingTest 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.scanned
X-QR3-Delivery: wdl_abc123
X-QR3-Attempt: 1
User-Agent: qr3-webhooks/1.0

Implementing 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 });
},
);

Retry Logic

On failure (HTTP >= 300 or timeout) qr3.app retries the delivery:

AttemptDelay
1Immediate
21 minute
35 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 id of the payload (evt_…). It belongs to the event and stays the same for every attempt and every repeat. X-QR3-Delivery names the single attempt: a repeat of that attempt carries the same value, a new attempt a new one. Acknowledge an id you 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 pending one hour after it was created (for example because its queue message was lost) is set to failed by a daily cleanup; status_code and response_time_ms stay null. This is not a failure of your endpoint and does not count towards the 10 consecutive failures. The log stores the text Delivery was not completed in time — closed by cleanup for 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

Terminal window
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:

Terminal window
POST /v1/webhooks/:id/ping

This sends a webhook.ping event to your endpoint URL.