ZapyFlow/API documentationopenapi.json

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

HeaderMeaning
X-ZapyFlow-EventThe public event name, e.g. order.created.
X-ZapyFlow-Event-IdStable across every attempt of the same event. This is your deduplication key.
X-ZapyFlow-Delivery-IdThis delivery to this endpoint. Changes per endpoint.
X-ZapyFlow-Attempt1-based attempt counter.
X-ZapyFlow-TimestampUnix seconds. Part of the signed string.
X-ZapyFlow-Signaturev1=<hex>. Comma-separated list during a secret rotation.

The signature scheme

The signed string is exactly:

text
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.

Verify against the raw body. The single most common integration failure is computing the HMAC over 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

javascript
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

bash
printf 'v1:%s:%s' "$TIMESTAMP" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex

Known-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

CaseTimestampBodyExpected signature
ascii simples1755600000{"event":"order.created","data":{"id":"ord_1"}}7af23a8a23693a3882586a61c28abebf193dc7a7bdf985ce3a93599e8a3f713d
utf-8 com acento e emoji1755600000{"event":"contact.updated","data":{"name":"Conceição 🇧🇷"}}6641c9068cfed3f201349adfb41c39d8375f1d65d12af53b378f050d716120d1
corpo vazio1755600000{}699e300ddb2512d8d76d3b7b53573193719ee3bd672088b1f934c3bbffb5e812
barra e menor-que, nao escapados1755600000{"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 responseWhat we do
2xxDelivery succeeded. Consecutive-failure counter resets.
3xxTerminal 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 errorRetried on the schedule below.
410 GoneEndpoint disabled immediately. That is what 410 means.
Other 4xxTerminal failure, no retry — a bad request will be just as bad the second time.

Retry schedule

Six attempts over roughly 31 hours, with jitter:

text
attempt 1  immediately
attempt 2  +30 seconds
attempt 3  +5 minutes
attempt 4  +30 minutes
attempt 5  +4 hours
attempt 6  +24 hours

If 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.

One limitation, stated rather than hidden: we resolve the hostname and then connect, so a DNS entry that changes between those two steps is not covered. Closing that requires pinning the resolved address at connect time, which our runtime does not support. We would rather write the boundary down than let it look complete.