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

# Verifying signatures

> Prove a webhook delivery came from Endl before you act on it

Each PUSH delivery is signed so you can prove it came from Endl. Always verify before acting on a webhook.

## Delivery headers

| Header                | Description                                                               |
| --------------------- | ------------------------------------------------------------------------- |
| `X-WEBHOOK-SIGNATURE` | 128 lowercase hex characters — HMAC-SHA512 over `timestamp + "." + body`. |
| `X-WEBHOOK-TIMESTAMP` | ISO-8601 UTC with three fraction digits. Also covered by the signature.   |
| `X-WEBHOOK-EVENT-ID`  | The event id. Use it as your idempotency key.                             |

<Warning>
  **Three things to get right.** It is SHA-**512**, not SHA-256. The hex is **lowercase**. And you must HMAC the **raw received body bytes** — parsing the JSON and re-serializing it changes those bytes and breaks the signature.
</Warning>

## Example delivery

```http theme={null}
POST /webhooks/endl HTTP/1.1
Host: api.acme.com
Content-Type: application/json
User-Agent: Endl-Webhooks/1.0
X-WEBHOOK-EVENT-ID: evt_ade9699dbee773755d7808d0
X-WEBHOOK-TIMESTAMP: 2026-09-03T09:55:10.372Z
X-WEBHOOK-SIGNATURE: d1d6553c34daa198aa8f259715efbd396f0c8a0658a4484f281d15536f889658e79f2e4538258899a4fea5500b0b7e196d2bf1a147e84a09f796291b76512dc5

{"eventId":"evt_ade9699dbee773755d7808d0","eventType":"payout.completed","eventCreatedAt":"2026-09-03T09:54:58.615Z","version":"1.0","data":{"referenceId":"3c77238d-42e1-44e7-a930-d4ae4bed4c5b","referenceType":"TRANSACTION","sourceAmount":"1500.00","sourceCurrency":"EUR","destinationAmount":"1387.50","destinationCurrency":"USD","fxRate":"0.9250","status":"COMPLETE","subStatus":"TRANSACTION_PROCESSED_SUCCESSFULLY"}}
```

The body is a single compact line, and the signature covers exactly those bytes. Its top level is always `eventId`, `eventType`, `eventCreatedAt`, `version`, and `data`; your event's own fields sit under `data`, where every leaf value is a string. No subscription id is sent.

## Verify the signature

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib

  def verify_webhook(raw_body: bytes, headers: dict, secrets: list[str]) -> bool:
      ts = headers["X-WEBHOOK-TIMESTAMP"]        # "2026-09-02T12:34:56.789Z"
      sig = headers["X-WEBHOOK-SIGNATURE"]       # 128 lowercase hex chars
      signed = ts.encode("utf-8") + b"." + raw_body
      for secret in secrets:                     # current, plus previous during rotation
          expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha512).hexdigest()
          if hmac.compare_digest(expected, sig):
              return True
      return False
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  function verifyWebhook(rawBody, headers, secrets) {
    const ts = headers["x-webhook-timestamp"];
    const sig = headers["x-webhook-signature"];
    const signed = Buffer.concat([Buffer.from(ts + "."), rawBody]);
    return secrets.some((secret) => {
      const expected = crypto.createHmac("sha512", secret).update(signed).digest("hex");
      const a = Buffer.from(expected), b = Buffer.from(sig);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });
  }
  ```
</CodeGroup>

<Note>
  Both examples compare in constant time and accept a **list** of secrets. That list is what makes rotation safe — see below.
</Note>

## Replay and rotation

The timestamp is covered by the signature, so you can trust it: reject deliveries older than about five minutes to bound replay.

Rotation is instantaneous server-side, with no overlap window. Endl starts signing with the new secret the moment you [rotate](/webhooks/subscriptions#rotate-the-signing-secret), so during your own rollout accept **either** the current or the previous secret until every instance has the new one.

<Tip>
  Deduplicate on `X-WEBHOOK-EVENT-ID`. Retries and manual sends can deliver the same event more than once, and a receiver that is idempotent on the event id handles all of it for free.
</Tip>
