Skip to main content
Your endpoint URL is public: anyone who finds the address can send a 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).
Verification is optional, but recommended for any endpoint that triggers a business action (replying to a customer, creating an order, updating a CRM).
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 own secret, generated by Zapfy when the webhook is created. It is returned only once, in the response of the POST that creates the webhook:
Store the secret as soon as you create the webhook: it does not appear again in any other API response. If you lose it, or suspect it has leaked, delete the webhook and create a new one — the new webhook comes with a new secret.
Treat the 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 your secret. It checks the signature, rejects requests older than 5 minutes and returns the parsed event.
There are official libraries for Go, Java, Ruby, Rust, C# and other languages too — see the list at standardwebhooks.com.

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 the secret without the whsec_ prefix, decoded from base64 — not the string itself:
  1. Read the raw body of the request, before any JSON parsing.
  2. Read webhook-id, webhook-timestamp and webhook-signature. If any is missing, reject.
  3. Reject if the timestamp is more than 5 minutes away from your clock.
  4. Compute the expected signature as above.
  5. 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
All three fields are part of the signed content, so none of them can be changed without breaking the signature. An attacker cannot change 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-timestamp is 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 timestamp in 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.
To also close the 5-minute window, combine it with deduplication by the event ID (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

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.
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.
webhook-timestamp is in seconds. Comparing it with Date.now() (milliseconds) without dividing by 1000 rejects everything. Also check your server clock.
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 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 your secret from the terminal:
Your endpoint should reply 2xx. Change one character of BODY after generating the signature, or use a TS from one hour ago, and it should reply 401.