Webhooks
We POST a JSON body to your URL when something happens. Every delivery is signed, carries a stable event id, and is retried on a published schedule.
Headers on every delivery
| Header | Meaning |
|---|---|
| X-ZapyFlow-Event | The public event name, e.g. order.created. |
| X-ZapyFlow-Event-Id | Stable across every attempt of the same event. This is your deduplication key. |
| X-ZapyFlow-Delivery-Id | This delivery to this endpoint. Changes per endpoint. |
| X-ZapyFlow-Attempt | 1-based attempt counter. |
| X-ZapyFlow-Timestamp | Unix seconds. Part of the signed string. |
| X-ZapyFlow-Signature | v1=<hex>. Comma-separated list during a secret rotation. |
The signature scheme
The signed string is exactly:
v1:<X-ZapyFlow-Timestamp>:<the exact request body bytes>HMAC-SHA256, hex encoded. The key is the whole secret string as shown in the dashboard, prefix included — there is nothing to strip or decode.
JSON.stringify(req.body) after a framework has parsed it. Re-serialising reorders keys and changes spacing, and the signature will never match. In Express, use express.raw() on the webhook route.Verifying in Node
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, headers, secret) {
const timestamp = Number(headers["x-zapyflow-timestamp"]);
const header = String(headers["x-zapyflow-signature"] ?? "");
// Reject anything older than five minutes: a valid signature stays valid
// forever, so the timestamp is what limits replay.
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`v1:${timestamp}:${rawBody}`, "utf8")
.digest("hex");
// A plain === leaks timing. Compare in constant time.
return header
.split(",")
.map((part) => part.trim().replace(/^v1=/, ""))
.some((candidate) =>
candidate.length === expected.length &&
timingSafeEqual(Buffer.from(candidate, "hex"), Buffer.from(expected, "hex")),
);
}Verifying from a shell
printf 'v1:%s:%s' "$TIMESTAMP" "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hexKnown-answer test vectors
Run your implementation against these before you go live. They are the same vectors our own test suite asserts on — including the two that catch real bugs: UTF-8 beyond ASCII, and a body containing / and <, which we do not escape.
Secret: whsec_exemplo_de_segredo_para_os_vetores
| Case | Timestamp | Body | Expected signature |
|---|---|---|---|
| ascii simples | 1755600000 | {"event":"order.created","data":{"id":"ord_1"}} | 7af23a8a23693a3882586a61c28abebf193dc7a7bdf985ce3a93599e8a3f713d |
| utf-8 com acento e emoji | 1755600000 | {"event":"contact.updated","data":{"name":"Conceição 🇧🇷"}} | 6641c9068cfed3f201349adfb41c39d8375f1d65d12af53b378f050d716120d1 |
| corpo vazio | 1755600000 | {} | 699e300ddb2512d8d76d3b7b53573193719ee3bd672088b1f934c3bbffb5e812 |
| barra e menor-que, nao escapados | 1755600000 | {"url":"https://a/b","note":"<b>"} | a8f589e48d487396f634b2d59915b543f61ec088d55b591e1d30b6a58ac0f643 |
Responding
Return any 2xx within 10 seconds. Do the real work asynchronously — we count a timeout as a failure and retry, so a slow handler turns one event into six.
| Your response | What we do |
|---|---|
| 2xx | Delivery succeeded. Consecutive-failure counter resets. |
| 3xx | Terminal failure, no retry. Point the endpoint at the final URL — we never follow redirects, because following one would reopen the SSRF protections we apply to your URL. |
| 408, 425, 429, 5xx, timeout, connection error | Retried on the schedule below. |
| 410 Gone | Endpoint disabled immediately. That is what 410 means. |
| Other 4xx | Terminal failure, no retry — a bad request will be just as bad the second time. |
Retry schedule
Six attempts over roughly 31 hours, with jitter:
attempt 1 immediately
attempt 2 +30 seconds
attempt 3 +5 minutes
attempt 4 +30 minutes
attempt 5 +4 hours
attempt 6 +24 hoursIf you return Retry-After on a 429, we honour it instead.
Automatic disabling
After 20 consecutive failed deliveries the endpoint is switched off and the workspace owner gets an email. The counter is per delivery, not per attempt — an endpoint that fails one event six times has failed once, not six times.
What we refuse to call
Endpoint URLs are validated when saved and again before every attempt: a hostname that resolves publicly today can be repointed at a private address tomorrow. We reject private, loopback, link-local and metadata addresses, require https, and refuse our own hostnames.