Skip to main content
ER ExaRoutes

Webhooks

ExaRoutes can POST every workspace event to a URL you control. Use it to pipe scans into your CDP, fan submissions into your CRM, or trigger Zaps without polling. Deliveries follow the Standard Webhooks spec. Signatures bind to a per-delivery id + timestamp so a captured payload can't be replayed.

Subscribing

  1. Open Settings → Integrations in the dashboard.
  2. Click Add webhook. Provide a name, your endpoint URL, and the events you want (or leave empty to subscribe to all).
  3. Copy the generated signing secret. It is shown once. You'll use it to verify every delivery.

Webhook URLs must be HTTPS in production and resolve to a public IP. Loopback, private-network, link-local, and AWS-metadata IPs are rejected at write time and re-checked per delivery (DNS-rebinding defence).

Event types

EventWhen it fires
scan.createdEvery successful redirect of a dynamic QR.
qr.createdA new dynamic QR is generated. Bulk runs do not emit per-row events.
qr.updatedA dynamic QR is edited (destination, expiry, styling, routing rules…).
qr.deletedA dynamic QR is soft-deleted (active: false). Future scans return 410 Gone.
form.submittedA scan-to-form submission landed.
domain.verifiedA custom redirect domain finished DNS verification.
subscription.activatedA new paid subscription started (initial checkout or free-trial start).
subscription.renewedAn existing subscription auto-renewed for another period.
subscription.payment_failedA renewal payment failed. The subscription is on hold pending retry.
subscription.cancelledThe subscription was cancelled (customer- or admin-initiated) or expired.
subscription.plan_changedThe subscription's plan tier changed (upgrade or downgrade).
subscription.refundedA payment on the subscription was refunded (full or partial).

Envelope

Every delivery has the same outer shape:

{
  "id":        "evt_8f24a1b9d011",
  "type":      "scan.created",
  "createdAt": "2026-04-27T15:32:09.812Z",
  "data":      { … event-specific fields … }
}

Treat id as the idempotency key. Retries reuse the same id, so dedupe on it before processing.

Headers

Standard-Webhooks canonical headers (preferred for new receivers):

HeaderDescription
webhook-idPer-event id (same as id in the body). Stable across retries — the SAME value arrives on every attempt for a given event. Use this for receiver-side idempotency.
webhook-timestampUnix seconds when the delivery was signed. Reject events older than ~5 minutes to defend against replay.
webhook-signaturev1,<base64>: HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw-body>, base64-encoded. Multiple comma-separated values may appear during secret rotation.

Event vs. attempt. An event is the thing that happened in your workspace (one scan, one form submission). A delivery attempt is each HTTP POST we make to your endpoint — up to 6 per event (initial + 5 retries).

  • webhook-id identifies the event. All 6 attempts carry the same value. Dedupe on this.
  • X-ExaRoutes-Delivery-Id identifies a single attempt. Each retry has a different value. Useful for support tickets / tracing one specific attempt in your logs.

Legacy X-ExaRoutes-* headers are also emitted as aliases for one release window (current state: deprecated but still sent). New receivers should verify against webhook-signature. The legacy headers will be removed in a future release.

Legacy headerDescription
X-ExaRoutes-Event-Id= webhook-id.
X-ExaRoutes-Event-Typee.g. scan.created.
X-ExaRoutes-Signaturesha256=<hex>: HMAC of the raw body alone. Vulnerable to replay. Prefer webhook-signature.
X-ExaRoutes-Webhook-IdIdentifies which subscription emitted this event.
X-ExaRoutes-Delivery-IdPer-attempt id. Retries reuse the same delivery id but bump X-ExaRoutes-Attempt.
X-ExaRoutes-Attempt1 to 6. Initial + 5 retries.

All deliveries also send User-Agent: ExaRoutes-Webhooks/1.0 (+https://exaroutes.com/docs/webhooks) and Content-Type: application/json.

Signature verification

HMAC-SHA256 the message <webhook-id>.<webhook-timestamp>.<raw-body> with your signing secret and base64-encode the result. Compare against the value after the v1, prefix in webhook-signature. Use a constant-time comparator.

Node.js

import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhook(rawBody, headers, secret) {
  const id        = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const sigHeader = headers['webhook-signature']
  if (!id || !timestamp || !sigHeader) return false

  // Reject stale deliveries (replay window).
  const ageSec = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(ageSec) || ageSec > 5 * 60) return false

  const expected = createHmac('sha256', secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest('base64')

  // sigHeader can hold multiple values during secret rotation: "v1,abc v1,def"
  return sigHeader.split(' ').some((entry) => {
    const [, b64] = entry.split(',')
    if (!b64 || b64.length !== expected.length) return false
    return timingSafeEqual(Buffer.from(b64), Buffer.from(expected))
  })
}

Python

import base64, hmac, hashlib, time

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
    eid       = headers.get('webhook-id')
    timestamp = headers.get('webhook-timestamp')
    sig       = headers.get('webhook-signature')
    if not (eid and timestamp and sig):
        return False
    if abs(int(time.time()) - int(timestamp)) > 5 * 60:
        return False
    msg = f"{eid}.{timestamp}.{raw_body.decode('utf-8')}".encode('utf-8')
    expected = base64.b64encode(
        hmac.new(secret.encode('utf-8'), msg, hashlib.sha256).digest()
    ).decode('utf-8')
    for entry in sig.split(' '):
        _, _, candidate = entry.partition(',')
        if hmac.compare_digest(candidate, expected):
            return True
    return False

Express receiver

import express from 'express'

const app = express()

// IMPORTANT: capture the RAW body for HMAC. Do not let express.json()
// re-serialize before you verify — JSON normalization changes bytes.
app.post(
  '/webhooks/exaroutes',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    if (!verifyWebhook(req.body.toString('utf8'), req.headers, process.env.EXAROUTES_WEBHOOK_SECRET)) {
      return res.status(401).end('bad signature')
    }
    const event = JSON.parse(req.body.toString('utf8'))
    // …handle event…
    return res.status(200).end()
  },
)

Event payloads

scan.created

Fires every time a dynamic QR is scanned (after the redirect is issued).

{
  "qrId":        "qr_abc",
  "shortId":     "h4k2x9",
  "country":     "GB",          // ISO 3166-1 alpha-2; "" if unresolved
  "device":      "mobile",      // "mobile" | "tablet" | "desktop"
  "routedTo":    "https://example.com/uk-landing",  // present when a routing rule fired
  "variantId":   "var_a",       // present when a campaign variant won the toss
  "variantName": "Hero A"
}

Raw IP and User-Agent are intentionally not shipped in webhooks. They live on the scan record for your operators to inspect.

qr.created

{
  "qrId":           "qr_abc",
  "shortId":        "h4k2x9",
  "dataType":       "url",
  "secure":         ["password"],   // omitted when no secure features set
  "subscriptionId": "sub_xyz"
}

qr.updated

{
  "qrId":           "qr_abc",
  "shortId":        "h4k2x9",
  "subscriptionId": "sub_xyz",
  "changedKeys":    ["data", "expiry"]    // top-level fields that were mutated
}

changedKeys excludes data / rawData blob payloads when only their content changed. Structural keys (routingRules, campaign, maxScans, etc.) are listed.

qr.deleted

Soft-delete (the QR row is marked active: false). Scans of a deleted QR return 410 Gone.

{
  "qrId":           "qr_abc",
  "shortId":        "h4k2x9",
  "subscriptionId": "sub_xyz"
}

form.submitted

A scan-to-form submission landed. To pull submission values, fetch the submission via the dashboard or the workspace forms API. The webhook payload only carries identifiers.

{
  "formId":       "form_abc",
  "formName":     "Trade-show capture",
  "submissionId": "1745772938_a8d1"
}

domain.verified

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

Subscription events

All subscription events include subscriptionId. Event-specific metadata is added per verb. Prefer these over polling Dodo directly. They fire only after we've reconciled the change against our DB, so you won't see ghost activations from disputed payments.

subscription.activated

{
  "subscriptionId":    "sub_xyz",
  "apiName":           "qr.growth",          // tier api name
  "product":           "QR Generator",
  "plan":              "Growth",
  "claimingFreeTrial": false                  // true on free-trial start
}

subscription.renewed

{
  "subscriptionId": "sub_xyz",
  "validUpto":      1781827200000   // epoch ms — new period end
}

subscription.payment_failed

{
  "subscriptionId": "sub_xyz",
  "previousStatus": "active"        // status before the failure
}

subscription.cancelled

{
  "subscriptionId": "sub_xyz"
}

subscription.plan_changed

{
  "subscriptionId": "sub_xyz",
  "apiName":        "qr.business"   // new tier
}

subscription.refunded

{
  "subscriptionId": "sub_xyz",
  "paymentId":      "pay_…",
  "refundId":       "rfd_…",
  "amount":         990,             // smallest currency unit
  "refundType":     "full"           // "full" | "partial"
}

Delivery + retries

  • Endpoints have 5 seconds to respond.
  • Any non-2xx response (or timeout, or DNS failure) triggers a retry.
  • Retry schedule: 30s, 5m, 30m, 2h, 6h. After the 6th total attempt fails, the delivery is marked failed.
  • After 5 consecutive failed deliveries we email the workspace owner once.
  • After 20 consecutive failures the webhook auto-disables (status: "disabled", disabledReason: "consecutive_failures"). Re-enabling from the dashboard resets the counter.
  • You can replay any delivery from the dashboard.

Building idempotent receivers

  • Dedupe on webhook-id (= id in the body). Retries reuse the same id.
  • Process events asynchronously: ack with 200 fast, then fan out to your worker. A slow downstream system shouldn't burn your retry budget.
  • Out-of-order delivery is possible (a retried older event can arrive after a newer one). Use createdAt when you need to enforce order.
  • During secret rotation the webhook-signature header may carry multiple v1,<sig> entries separated by spaces. Accept the delivery if any entry verifies.