Webhooks

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.

Format

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-idUnique delivery ID. Use it to deduplicate (idempotency).
webhook-timestampSend time (unix seconds). Use it for replay protection.
webhook-signatureSignature as v1,<base64>. May contain several space-separated signatures.
Content-TypeAlways application/json.
Events

Which events you get

Choose which events to subscribe to. The contents of the data field differ per event type.

eventDescriptiondata
scan.createdA QR code was scanned.qrCodeId, deviceType, countryCode
conversion.createdA visitor converted (CTA click or form submit).qrCodeId, eventType, sessionToken, pageUrl, occurredAt
qr.createdA new QR code was created.id, name, type
qr.limit_warningA QR code is approaching its scan limit.used, limit, percent
campaign.publishedA campaign was published.id, name
bot.detectedA scan was classified as a bot.qrCodeId, botName, botCategory
webhook.testTest event from the Test button in settings.message
Security

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;
}

Always verify against the header values, not the body. Reject requests older than a few minutes (replay protection) and spot duplicates by webhook-id.

Delivery

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.