Webhooks

ExaRoutes can deliver a webhook for QR scans, form submissions, lifecycle changes, and billing events. Use webhooks to forward events to your own systems (CRM, data warehouse, Slack notifier, etc.).

Configuring a webhook

From the dashboard at Settings → Integrations, add a webhook URL. Each webhook is workspace-scoped and can be subscribed to specific events:

  • scan.created: every dynamic QR scan
  • form.submitted: every hosted-form submission
  • qr.created, qr.updated, qr.deleted
  • domain.verified
  • serialized.first_scanned: the first (genuine) scan of a serialized code
  • serialized.duplicate_scanned: a scan of a code that was already claimed (possible counterfeit)
  • subscription.activated, subscription.renewed, subscription.cancelled, subscription.payment_failed, subscription.plan_changed, subscription.refunded

Delivery envelope

Every delivery is a POST with this JSON envelope; data varies per event:

{
  "id": "evt_abc123",
  "type": "scan.created",
  "createdAt": "2026-04-25T15:00:00Z",
  "data": { }
}

Event payloads (data)

scan.created

{
  "qrId": "qr_abc123",
  "shortId": "ABC123",
  "country": "US",
  "device": "mobile",
  "routedTo": "https://yourbrand.com/us",
  "variantId": "a",
  "variantName": "Landing A"
}

routedTo is present only when a routing rule fired; variantId / variantName only when an A/B campaign picked a variant. The scanner’s IP and user agent are never sent.

form.submitted

{ "formId": "form_abc123", "formName": "Event RSVP", "submissionId": "sub_9y8x7w" }

Fetch the submission values via GET /api/qr/forms/{formId}/submissions; they are not included in the webhook.

qr.created

{ "qrId": "qr_abc123", "shortId": "ABC123", "dataType": "url", "secure": [], "subscriptionId": "sub_..." }

qr.updated

{ "qrId": "qr_abc123", "shortId": "ABC123", "subscriptionId": "sub_...", "changedKeys": ["data", "style"] }

qr.deleted

{ "qrId": "qr_abc123", "shortId": "ABC123", "subscriptionId": "sub_..." }

domain.verified

{ "domain": "qr.yourbrand.com" }

Serialized scans

Both serialized events share one data shape; serialized.duplicate_scanned is the possible-counterfeit signal (the code had already been claimed):

{
  "itemId": "a1b2c3d4e5f6a7b8",
  "batchId": "batch_9y8x7w6v5u",
  "productSku": "ACME-1234",
  "productName": "ACME Premium Widget",
  "scanCount": 2,
  "firstScannedAt": 1717100000000,
  "country": "US"
}

firstScannedAt is when the code was originally claimed (epoch ms); country is the coarse country of this scan.

subscription.*

Billing lifecycle events carry the subscriptionId plus the provider metadata for the change (plan, status, billing period). Treat the shape as informational and re-fetch the subscription from the API for decisions.

Signing

Deliveries are signed with the Standard Webhooks scheme. Each request carries:

  • webhook-id: the delivery id
  • webhook-timestamp: Unix seconds
  • webhook-signature: v1,<base64 HMAC-SHA256> computed over "{webhook-id}.{webhook-timestamp}.{raw body}" with your webhook secret

Verify with any Standard Webhooks library, and reject deliveries whose timestamp is too old (replay protection). The legacy X-ExaRoutes-Signature: sha256=<hex> header (HMAC of the body alone) is still sent for backward compatibility, but new integrations should verify the Standard Webhooks headers.

Retries

Failed deliveries (non-2xx response or timeout) are retried with exponential backoff up to 5 attempts: 30s, 5m, 30m, 2h, 6h. After the final attempt, the delivery is marked failed and visible in the dashboard’s webhook delivery log.