Skip to content

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.

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.

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.

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())
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.

  • 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-id and 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 2xx within 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.

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.