POST to it.
That is why every Zapfy delivery is signed with a secret that only you and Zapfy
know. Verifying the signature guarantees three things:
- Origin — the request was sent by Zapfy.
- Integrity — the body arrived exactly as it was sent, without a single byte changed.
- Freshness — it is not an old request that was captured and sent again (replay).
Deliveries follow the open Standard Webhooks
specification, used by many webhook providers. In practice, you verify them with the
standard’s official library, without writing any cryptography code.
The secret
Each webhook has its ownsecret, generated by Zapfy when the webhook is created. It
is returned only once, in the response of the POST that creates the webhook:
secret like a password: keep it in an environment variable or a secrets
manager, never in source code or logs. Anyone who has it can forge a valid delivery.
Delivery headers
The event type has no header of its own: it is in the
type field of the body,
which is signed.
Verifying with the official library
Install the Standard Webhooks library and give it the raw body of the request, the headers and yoursecret. It checks the signature, rejects requests older than
5 minutes and returns the parsed event.
Verifying without a library
If you would rather not add a dependency, verification fits in a few lines. The signature is an HMAC-SHA256 of the ID, the timestamp and the raw body, separated by dots. The key is thesecret without the whsec_ prefix, decoded
from base64 — not the string itself:
- Read the raw body of the request, before any JSON parsing.
- Read
webhook-id,webhook-timestampandwebhook-signature. If any is missing, reject. - Reject if the timestamp is more than 5 minutes away from your clock.
- Compute the expected signature as above.
- The header may carry more than one signature, separated by spaces. Accept if any of them equals the expected one, using a constant-time comparison.
Node.js
webhook-timestamp to make
a captured request look recent, nor change webhook-id to get past your
deduplication.
Replay protection
The signature alone proves that the request came from Zapfy, but not when. Without the timestamp check, someone who captured a valid delivery could send it again days later and it would pass. The 5-minute tolerance closes that window — the official library already does this check.webhook-timestampis the time the delivery was sent, stamped on every attempt. A retry made after a failure arrives with a new timestamp and passes the tolerance as usual.- It is not the
timestampin the body, which is the time of the event and stays the same across all attempts. - Keep your server clock in sync (NTP). A clock that is a few minutes off starts rejecting legitimate deliveries.
webhook-id, or id in the body — they hold the same value), which you already
need because delivery is at-least-once. A replay inside the window carries the ID of
an event you already processed, and is dropped; and since the ID is signed, changing
it invalidates the request.
Common mistakes
The signature never matches
The signature never matches
It is almost always the body. If your framework parses the JSON and you verify the
result of
JSON.stringify(req.body), the bytes change (whitespace, key order,
escaping) and the HMAC is different. Use the raw body, exactly as it arrived —
in Express, express.raw; in Flask, request.get_data(); in PHP, php://input.My own implementation fails, but the library works
My own implementation fails, but the library works
The HMAC key is the
secret decoded from base64, after removing the whsec_
prefix. Using the whsec_... string directly as the key produces a different
signature. The signature is also base64, not hex.Timestamp always outside the tolerance
Timestamp always outside the tolerance
webhook-timestamp is in seconds. Comparing it with Date.now() (milliseconds)
without dividing by 1000 rejects everything. Also check your server clock.Why not compare with ==
Why not compare with ==
A regular comparison stops at the first different character, and the response
time leaks how many characters were right. Use
crypto.timingSafeEqual,
hmac.compare_digest or hash_equals — the official libraries already do.Header not found
Header not found
Header names are case-insensitive. In Node they arrive in lowercase
(
req.headers["webhook-id"]); in PHP, in $_SERVER with the HTTP_ prefix
(HTTP_WEBHOOK_ID).Testing your endpoint
To test your verification without waiting for a real event, generate a signed request with yoursecret from the terminal:
2xx. Change one character of BODY after generating the
signature, or use a TS from one hour ago, and it should reply 401.