• Getting started
    • Create your first code
    • Static vs dynamic codes
    • Plans and limits
  • QR codes
    • Code types
    • Editing a destination
    • Design and export
    • Folders, tags and placement
    • Pausing, archiving, expiry and scan limits
    • Code links
    • Custom domains
    • Safety checks and disabled codes
    • Put your menu behind a QR code
  • Analytics
    • What's tracked
    • History and plans
    • Exporting scans
  • Redirect rules
    • Conditions
    • A/B tests
    • Examples
  • Bio pages and lead capture
    • Create a bio page
    • Lead forms
    • Managing leads
  • Integrations
    • UTM tags
    • Google Analytics 4
    • Webhooks
    • Zapier
    • Bulk CSV import
  • Workspaces and team
    • Roles
    • Inviting people
    • Transferring ownership
    • Audit log
  • Reports
    • Schedule a report
    • Delivery, PDFs and unsubscribing
  • Billing
    • Upgrading and downgrading
    • Managing your subscription
  • Account and security
    • Signing in
    • Two-step verification
    • Deleting a workspace or account
  • REST API
    • Authentication
    • Errors and rate limits
    • Pagination
    • GET /me
    • Codes
    • A single code
    • Scan stats
    • Redirect rules
    • Bio pages
    • Leads
    • Hooks
    • Webhooks
  • AI assistants (MCP)
    • Connect Claude
    • Connect ChatGPT
    • Sign-in and access
    • Tools reference
    • Example prompts
    • Troubleshooting
    • Free static QR codes (no sign-in)

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.

  1. Enter the Endpoint URL.
  2. Pick the Events. For scan.threshold, also enter a Scan threshold.
  3. 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

EventSent when
scan.createdA person scans a code. Bot scans are not sent
lead.createdA visitor submits a lead form
scan.thresholdTotal 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 2xx status within 5 seconds. Do slow work after replying.
  • Redirects are not followed, so a 3xx reply 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 Gone to stop a delivery without retries. For hooks created through the hooks endpoints, 410 also switches the hook off.
  • Deliveries can arrive more than once and out of order. Use data.id to skip duplicates of scan.created and lead.created.

On the Integrations page, Recent deliveries in the Webhooks card shows the last 50 deliveries with their status, attempts and last error.

DynaQR
Help