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
- Open Settings → Integrations in the dashboard.
- Click Add webhook. Provide a name, your endpoint URL, and the events you want (or leave empty to subscribe to all).
- 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
| Event | When it fires |
|---|---|
scan.created | Every successful redirect of a dynamic QR. |
qr.created | A new dynamic QR is generated. Bulk runs do not emit per-row events. |
qr.updated | A dynamic QR is edited (destination, expiry, styling, routing rules…). |
qr.deleted | A dynamic QR is soft-deleted (active: false). Future scans return 410 Gone. |
form.submitted | A scan-to-form submission landed. |
domain.verified | A custom redirect domain finished DNS verification. |
subscription.activated | A new paid subscription started (initial checkout or free-trial start). |
subscription.renewed | An existing subscription auto-renewed for another period. |
subscription.payment_failed | A renewal payment failed. The subscription is on hold pending retry. |
subscription.cancelled | The subscription was cancelled (customer- or admin-initiated) or expired. |
subscription.plan_changed | The subscription's plan tier changed (upgrade or downgrade). |
subscription.refunded | A 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):
| Header | Description |
|---|---|
webhook-id | Per-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-timestamp | Unix seconds when the delivery was signed. Reject events older than ~5 minutes to defend against replay. |
webhook-signature | v1,<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-ididentifies the event. All 6 attempts carry the same value. Dedupe on this.X-ExaRoutes-Delivery-Ididentifies 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 header | Description |
|---|---|
X-ExaRoutes-Event-Id | = webhook-id. |
X-ExaRoutes-Event-Type | e.g. scan.created. |
X-ExaRoutes-Signature | sha256=<hex>: HMAC of the raw body alone. Vulnerable to replay. Prefer webhook-signature. |
X-ExaRoutes-Webhook-Id | Identifies which subscription emitted this event. |
X-ExaRoutes-Delivery-Id | Per-attempt id. Retries reuse the same delivery id but bump X-ExaRoutes-Attempt. |
X-ExaRoutes-Attempt | 1 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(=idin 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
createdAtwhen you need to enforce order. - During secret rotation the
webhook-signatureheader may carry multiplev1,<sig>entries separated by spaces. Accept the delivery if any entry verifies.