> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zapfy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Security

> Confirm that each webhook came from Zapfy, that the body was not changed, and that it is not an old request being replayed.

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

<Info>
  Deliveries follow the open [Standard Webhooks](https://www.standardwebhooks.com)
  specification, used by many webhook providers. In practice, you verify them with the
  standard's **official library**, without writing any cryptography code.
</Info>

## 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:

```json theme={null}
{
  "data": {
    "id": "01J9Z...",
    "url": "https://your-app.com/webhooks",
    "events": ["MESSAGE.RECEIVED", "MESSAGE.STATUS_UPDATED"],
    "enabled": true,
    "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw...",
    "createdAt": "2026-09-26T01:40:00.000Z"
  }
}
```

<Warning>
  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`.
</Warning>

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

| Header | Content |
| - | - |
| `webhook-id` | Event ID. Same as `id` in the body and stable across retries — use it to deduplicate. |
| `webhook-timestamp` | Time the delivery was sent, in Unix seconds (e.g. `1790388490`). |
| `webhook-signature` | Delivery signature: `v1,<base64>`. |

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.

<CodeGroup>
  ```bash Node.js theme={null}
  npm install standardwebhooks
  ```

  ```bash Python theme={null}
  pip install standardwebhooks
  ```

  ```bash PHP theme={null}
  composer require standard-webhooks/standard-webhooks
  ```
</CodeGroup>

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import express from "express"
  import { Webhook } from "standardwebhooks"

  const wh = new Webhook(process.env.WEBHOOK_SECRET)
  const app = express()

  // express.raw: the body arrives as a Buffer, byte for byte as Zapfy sent it.
  app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
    let event
    try {
      event = wh.verify(req.body.toString("utf8"), req.headers)
    } catch {
      return res.status(401).end()
    }

    // ... process the event (deduplicating by event.id)
    res.status(200).end()
  })
  ```

  ```python Python (Flask) theme={null}
  import os

  from flask import Flask, abort, request
  from standardwebhooks.webhooks import Webhook, WebhookVerificationError

  wh = Webhook(os.environ["WEBHOOK_SECRET"])
  app = Flask(__name__)


  @app.post("/webhooks")
  def webhook():
      try:
          event = wh.verify(request.get_data(), dict(request.headers))
      except WebhookVerificationError:
          abort(401)

      # ... process the event (deduplicating by event["id"])
      return "", 200
  ```

  ```php PHP theme={null}
  <?php
  require __DIR__ . '/vendor/autoload.php';

  use StandardWebhooks\Webhook;
  use StandardWebhooks\Exception\WebhookVerificationException;

  $wh = new Webhook(getenv('WEBHOOK_SECRET'));

  $headers = [
      'webhook-id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? null,
      'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null,
      'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null,
  ];

  try {
      $event = $wh->verify(file_get_contents('php://input'), $headers);
  } catch (WebhookVerificationException $e) {
      http_response_code(401);
      exit;
  }

  // ... process the event (deduplicating by $event['id'])
  http_response_code(200);
  ```
</CodeGroup>

There are official libraries for Go, Java, Ruby, Rust, C# and other languages too —
see the list at [standardwebhooks.com](https://www.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:

```text theme={null}
key               = base64_decode( secret without "whsec_" )
signed content    = <webhook-id> + "." + <webhook-timestamp> + "." + <raw body>
webhook-signature = "v1," + base64( HMAC-SHA256(key, signed content) )
```

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

```js Node.js theme={null}
import crypto from "node:crypto"

const TOLERANCE_SECONDS = 300

function verifyWebhook({ rawBody, headers, secret }) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const signatures = headers["webhook-signature"]
  if (!id || !timestamp || !signatures) return false

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest()

  return signatures.split(" ").some((versioned) => {
    const [version, signature] = versioned.split(",")
    if (version !== "v1" || !signature) return false
    const received = Buffer.from(signature, "base64")
    return received.length === expected.length && crypto.timingSafeEqual(received, expected)
  })
}
```

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](/en/webhooks/events#envelope) 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

<AccordionGroup>
  <Accordion title="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`.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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`).
  </Accordion>
</AccordionGroup>

## Testing your endpoint

To test your verification without waiting for a real event, generate a signed
request with your `secret` from the terminal:

```bash theme={null}
SECRET="whsec_..."
URL="https://your-app.com/webhooks"
BODY='{"type":"MESSAGE.RECEIVED","id":"test-1","instanceId":"test","timestamp":"2026-09-26T01:40:00Z","data":{}}'
ID="test-1"
TS=$(date +%s)
KEY=$(printf '%s' "${SECRET#whsec_}" | openssl base64 -d -A | xxd -p -c 256)
SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY" -binary | openssl base64 -A)

curl -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "webhook-id: $ID" \
  -H "webhook-timestamp: $TS" \
  -H "webhook-signature: v1,$SIG" \
  -d "$BODY"
```

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.