Webhooks
Every click and scan can be delivered to your systems as a signed JSON event within seconds. This page is the complete receiving-side reference.
The event
Section titled “The event”Webhook-class channels (generic webhook, Zapier, Make, n8n, Pipedream, Power Automate, Home Assistant) receive:
{ "event": "click", "source": "qr", "code": "spring24", "shortUrl": "go.acme.com/spring24", "title": "Spring offer", "destination": "https://acme.com/spring-offer", "tags": { "campaign": "spring", "site": "leeds" }, "ts": 1752321600000, "at": "2026-07-12T11:20:00+00:00", "ip": "203.0.113.7", "ua": "Mozilla/5.0 (iPhone...", "referer": null, "customerId": "d42ccae0-…"}source distinguishes how the event arrived: link (direct click), qr,
nfc, card or hosted.
Signature headers
Section titled “Signature headers”Every delivery to an endpoint you own carries Svix-compatible headers:
| Header | Value |
|---|---|
webhook-id |
evt_… — stable per event, identical across retries (your de-dupe key) |
webhook-timestamp |
Unix seconds at send time (fresh per attempt — use for replay windows) |
webhook-signature |
v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{raw body} |
The HMAC key is the base64-decoded part of your channel’s whsec_ secret
(shown once at channel creation).
Verification is optional. Ignore the headers and delivery works identically — verify only if you want cryptographic certainty the event came from xengo.
Verifying — Python (stdlib only)
Section titled “Verifying — Python (stdlib only)”import base64, hashlib, hmac
def verify(secret: str, headers: dict, raw_body: bytes) -> bool: key = base64.b64decode(secret.split("_", 1)[1]) signed = (f"{headers['webhook-id']}." f"{headers['webhook-timestamp']}.").encode() + raw_body want = base64.b64encode( hmac.new(key, signed, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(f"v1,{want}", candidate) for candidate in headers["webhook-signature"].split())Verifying — Node
Section titled “Verifying — Node”import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody) { const key = Buffer.from(secret.split("_")[1], "base64"); const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.`; const want = createHmac("sha256", key) .update(Buffer.concat([Buffer.from(signed), rawBody])) .digest("base64"); return headers["webhook-signature"] .split(" ") .some((c) => { const a = Buffer.from(c); const b = Buffer.from(`v1,${want}`); return a.length === b.length && timingSafeEqual(a, b); });}Any official Svix verification library also works — pass the whsec_ secret
and the three headers.
Recommended receiver checks: signature matches; webhook-timestamp within
±5 minutes; de-dupe on webhook-id.
Delivery semantics — honestly
Section titled “Delivery semantics — honestly”- Latency: typically seconds. Events leave the redirect path immediately via a queue, so a slow receiver never slows a visitor. We don’t claim sub-second.
- At-least-once: rare duplicates are possible after timeouts — de-dupe on
webhook-idand your logic is effectively exactly-once. - Retries: failed deliveries retry with backoff — 30 s → 2 m → 10 m → 15 m → 15 m (six attempts, ~45 minutes of outage cover). Deleted or disabled channels stop retrying immediately.
- Timeout: respond
2xxwithin 5 seconds. Do heavy work async — accept, enqueue, return. - Delivery log: every attempt (first try and each retry) is recorded — timestamp, link, attempt number, HTTP result — visible per channel in the console, retained 7/30/90 days by plan.
Test deliveries
Section titled “Test deliveries”Send test in the console fires a real, signed delivery
("event": "test") so you can validate your verification code end to end
before going live.
