Webhooks
Event types, payloads, the signature header and how to verify it, and retries.
Webhooks send an HTTPS POST to your URL when something happens in the workspace. They need the Pro or Business plan.
To set one up, open Integrations and go to the Webhooks card.
- Enter the Endpoint URL.
- Pick the Events. For
scan.threshold, also enter a Scan threshold. - Click Add webhook.
The Signing secret starts with whsec_ and is shown only once. Copy it now. To add or remove subscriptions from your own code, use the hooks endpoints.
Events
| Event | Sent when |
|---|---|
scan.created | A person scans a code. Bot scans are not sent |
lead.created | A visitor submits a lead form |
scan.threshold | Total human scans (of one code, or of the whole workspace) reach the threshold. Sent once, on the batch that crosses it |
Request
POST /your/endpoint HTTP/1.1 Content-Type: application/json User-Agent: DynaQR-Webhooks/1 X-QRM-Event: scan.created X-QRM-Signature: t=1763112000,v1=5f1c0e…
The body is always {"event": …, "data": …}.
scan.created
{
"event": "scan.created",
"data": {
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"codeId": "0b8f6c2e-3f7a-4d0e-9a61-2f4c7f1d9b10",
"organizationId": "Kp3vX9wQm2tR7bLc",
"hostname": "qrmgr.link",
"ts": "2026-11-14T09:30:12.000Z",
"source": "qr",
"country": "GB",
"region": "ENG",
"city": "London",
"device": "ios",
"os": "iOS",
"browser": "Safari",
"language": "en-GB",
"ruleId": null,
"isBot": false
}
}
device is ios, android, desktop or other. source is link when the short link was opened with ?src=link, otherwise qr. ruleId is the matched redirect rule (rule:variant for A/B splits). Location, os, browser and language can be null. IP addresses are never sent.
lead.created
{
"event": "lead.created",
"data": {
"id": "5a7c9e1b-2d4f-4a6b-8c0d-e1f2a3b4c5d6",
"codeId": "0b8f6c2e-3f7a-4d0e-9a61-2f4c7f1d9b10",
"organizationId": "Kp3vX9wQm2tR7bLc",
"formId": "9c8b7a6d-5e4f-4321-8abc-def012345678",
"ts": "2026-11-13T18:22:05.000Z",
"values": {
"firstName": "Ana",
"lastName": "Silva",
"email": "[email protected]",
"phone": "+447700900123"
},
"consent": {
"terms": { "shown": true, "text": "I agree to the terms of Corner Cafe.", "checked": true, "url": "https://example.com/terms" },
"marketing": { "shown": true, "text": "Send me offers from Corner Cafe.", "checked": false },
"at": "2026-11-13T18:22:05.000Z"
},
"country": "GB",
"region": "ENG",
"city": "London",
"device": "ios",
"utm": { "source": "qr" }
}
}
values holds only the fields the form asks for, as in Leads, including message when the form has a Message field. A lead that comes in after the plan's monthly lead limit is reached isn't sent straight away. It's sent once the plan allows it, for example after an upgrade or at the start of the next month.
scan.threshold
{
"event": "scan.threshold",
"data": {
"threshold": 1000,
"scans": 1003,
"codeId": "0b8f6c2e-3f7a-4d0e-9a61-2f4c7f1d9b10"
}
}
codeId is null for a workspace-wide threshold. scans is the total when the threshold was crossed.
Verify the signature
X-QRM-Signature has the form t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw body>, keyed with your signing secret (the whole whsec_… string). Check it against the raw request body, before parsing the JSON, and reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyQrmSignature({ header, rawBody, secret }) {
const parts = Object.fromEntries(
(header ?? '').split(',').map((part) => part.split('=', 2)),
);
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = Buffer.from(parts.v1, 'hex');
const wanted = Buffer.from(expected, 'hex');
return received.length === wanted.length && timingSafeEqual(received, wanted);
}
With Express, keep the raw body:
import express from 'express';
const app = express();
app.post(
'/webhooks/qrm',
express.raw({ type: 'application/json' }),
(request, response) => {
const rawBody = request.body.toString('utf8');
const valid = verifyQrmSignature({
header: request.get('x-qrm-signature'),
rawBody,
secret: process.env.QRM_WEBHOOK_SECRET,
});
if (!valid) return response.sendStatus(400);
const { event, data } = JSON.parse(rawBody);
// Handle the event, ideally in a background job.
response.sendStatus(200);
},
);
Responding and retries
- Reply with any
2xxstatus within 5 seconds. Do slow work after replying. - Redirects are not followed, so a
3xxreply counts as a failure. - Failed deliveries are retried up to 8 attempts in total, waiting 2, 4, 8, 16, 32, 64 and 128 minutes between them (a little over 4 hours overall). After that the delivery is marked failed.
- Reply
410 Goneto stop a delivery without retries. For hooks created through the hooks endpoints,410also switches the hook off. - Deliveries can arrive more than once and out of order. Use
data.idto skip duplicates ofscan.createdandlead.created.
On the Integrations page, Recent deliveries in the Webhooks card shows the last 50 deliveries with their status, attempts and last error.