Verifying and handling webhooks
Whenever a subscribed event happens, QRflex sends an HTTP POST to your URL. Every request is signed, so you can confirm it really came from us and was not altered in transit. Enable webhooks in Settings → Webhooks.
Envelope and headers
Every request body uses the same envelope; verification metadata lives in the headers.
Envelope (POST body)
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
"event": "scan.created",
"createdAt": "2026-10-12T10:09:50+02:00",
"data": {
"qrCodeId": "0f8e...",
"deviceType": "mobile",
"countryCode": "CZ"
}
}
Request headers
webhook-id | Unique delivery ID. Use it to deduplicate (idempotency). |
webhook-timestamp | Send time (unix seconds). Use it for replay protection. |
webhook-signature | Signature as v1,<base64>. May contain several space-separated signatures. |
Content-Type | Always application/json. |
Which events you get
Choose which events to subscribe to. The contents of the data field differ per event type.
event | Description | data |
|---|---|---|
scan.created | A QR code was scanned. | qrCodeId, deviceType, countryCode |
conversion.created | A visitor converted (CTA click or form submit). | qrCodeId, eventType, sessionToken, pageUrl, occurredAt |
qr.created | A new QR code was created. | id, name, type |
qr.limit_warning | A QR code is approaching its scan limit. | used, limit, percent |
campaign.published | A campaign was published. | id, name |
bot.detected | A scan was classified as a bot. | qrCodeId, botName, botCategory |
webhook.test | Test event from the Test button in settings. | message |
How to verify the signature
From the headers, build the string {webhook-id}.{webhook-timestamp}.{body} and compute its HMAC-SHA256 with your secret — first strip the whsec_ prefix from the secret and base64-decode the rest. Then compare the result in constant time with the value in the webhook-signature header (without the v1, prefix).
function verify(string $secret, array $headers, string $rawBody): bool {
$id = $headers['webhook-id'] ?? '';
$ts = $headers['webhook-timestamp'] ?? '';
$hdr = $headers['webhook-signature'] ?? '';
if (abs(time() - (int) $ts) > 300) return false; // replay
$key = base64_decode(substr($secret, strlen('whsec_')));
$signed = $id . '.' . $ts . '.' . $rawBody;
$expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
foreach (explode(' ', $hdr) as $part) { // "v1,xxx v1,yyy"
[$ver, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($ver === 'v1' && hash_equals($expected, $sig)) return true;
}
return false;
}
const crypto = require('crypto');
function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const ts = headers['webhook-timestamp'];
const hdr = headers['webhook-signature'] || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const signed = `${id}.${ts}.${rawBody}`;
const expected = crypto.createHmac('sha256', key).update(signed).digest('base64');
return hdr.split(' ').some((part) => {
const [ver, sig] = part.split(',');
if (ver !== 'v1' || !sig) return false;
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
hdr = headers.get("webhook-signature", "")
if abs(time.time() - int(ts)) > 300: # replay
return False
key = base64.b64decode(secret[len("whsec_"):])
signed = f"{msg_id}.{ts}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for part in hdr.split(" "): # "v1,xxx v1,yyy"
ver, _, sig = part.partition(",")
if ver == "v1" and hmac.compare_digest(sig, expected):
return True
return False
Always verify against the header values, not the body. Reject requests older than a few minutes (replay protection) and spot duplicates by webhook-id.
Retries and auto-disable
If your server does not return 2xx, we retry delivery.
Up to 5 attempts with delays of 5 minutes, 1 hour, 6 hours and 12 hours.
After 10 consecutive failures we disable the webhook automatically (status disabled).
We keep delivery history for 30 days.
Try delivery with the Test button in settings; for a quick check without your own server use webhook.site.