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

# Webhook security

> Verify webhook signatures and build idempotent, safe consumers.

Treat every webhook as untrusted input until you have verified it and reconciled
it against the [Transactions](/api-reference/introduction) API.

## Signature

Each delivery includes an `X-Zeam-Signature` header of the form
`sha256=<hex>`, where `<hex>` is an HMAC-SHA256 of the exact raw request body.
The **HMAC key is the `X-Webhook-Id` header value** — the UUID that uniquely
identifies this delivery.

To verify, recompute the HMAC over the raw body bytes and compare it to the
header using a constant-time comparison. Use the raw bytes before any JSON
parsing or re-serialisation.

<CodeGroup>
  ```javascript Node.js theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  import crypto from "node:crypto";

  // rawBody: the exact request body bytes/string (before any JSON parsing).
  // webhookId: the value from the X-Webhook-Id header.
  export function isValidSignature(rawBody, signatureHeader, webhookId) {
    const expected = crypto
      .createHmac("sha256", webhookId)
      .update(rawBody, "utf8")
      .digest("hex");
    const received = (signatureHeader || "").replace(/^sha256=/, "");
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(received, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  import hmac, hashlib

  def is_valid_signature(raw_body: bytes, signature_header: str, webhook_id: str) -> bool:
      expected = hmac.new(webhook_id.encode(), raw_body, hashlib.sha256).hexdigest()
      received = (signature_header or "").removeprefix("sha256=")
      return hmac.compare_digest(expected, received)
  ```
</CodeGroup>

<Warning>
  The HMAC key is the `X-Webhook-Id` value — a per-delivery UUID that is
  transmitted in the same request. This protects against accidental corruption
  and casual tampering but is **not** strong proof of origin, since the key is
  visible to anyone who intercepts the delivery. Always:

  * require HTTPS and validate TLS on your endpoint;
  * treat the signature as an integrity check, not authentication;
  * reconcile every event against the Transactions API before acting;
  * restrict who can reach your endpoint where you can.
</Warning>

## Replay protection

Each delivery includes an `X-Webhook-Ts` header containing the delivery
timestamp as Unix milliseconds (UTC). Use it to reject stale deliveries and
narrow the window in which a captured request could be replayed:

<CodeGroup>
  ```javascript Node.js theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  // Reject deliveries older than 5 minutes.
  export function isWithinWindow(tsHeader, windowMs = 5 * 60 * 1000) {
    const ts = parseInt(tsHeader || "0", 10);
    return Math.abs(Date.now() - ts) <= windowMs;
  }
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
  import time

  def is_within_window(ts_header: str, window_ms: int = 5 * 60 * 1000) -> bool:
      ts = int(ts_header or 0)
      return abs(int(time.time() * 1000) - ts) <= window_ms
  ```
</CodeGroup>

Combine the timestamp window with `X-Webhook-Id` deduplication: persist each
processed `X-Webhook-Id` and ignore any delivery whose ID you have already seen,
regardless of timestamp.

## Build an idempotent consumer

* Persist a processed-event key (the `X-Webhook-Id`, or `intentId` plus event
  type) and ignore duplicates.
* Return a `2xx` for an event you have already processed.
* Separate acknowledgement from processing: acknowledge fast, then process on a
  background worker.
* Log deliveries safely. Never log the raw signature key, tokens, or full
  personal data.
* Reconcile webhook state with the Transactions API rather than trusting the
  event in isolation.

## IP allowlisting

Zeam does not currently publish a fixed set of delivery IP addresses, so do not
rely on IP allowlisting as your only control. Use signature verification and
reconciliation instead.
