# Verify webhook signatures

URL: https://useembers.com/help/integrations/verify-webhook-signatures/
Category: Integrations
Plan: Solo and above
Updated: 2026-09-12
Last verified: 2026-09-12

> Check the Standard Webhooks headers on every Embers delivery: compute the HMAC, compare it safely, bound the timestamp, and deduplicate replays.

Anyone can `POST` JSON at your URL. The signature headers are how you know a request really came from Embers, and that nobody changed it on the way.

> **Warning: Solo and above, administrators only**
>
> Signing secrets are created and rotated under Developer, then Webhooks (`/developer/webhooks`) on Solo or above, by an account administrator.

## The headers

Embers uses Standard Webhooks HMAC-SHA256 headers on every delivery:

| Header | What it is |
| --- | --- |
| `webhook-id` | A stable UUID for the event. Use it for idempotency |
| `webhook-timestamp` | Unix seconds for this delivery attempt |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 |

## How to verify

### Keep the raw body

Capture the request body as raw bytes or a raw string before any JSON parsing. The signature is computed over exactly what was sent.

### Decode the secret

The secret is shown once as `whsec_` followed by base64. Strip the `whsec_` prefix and base64-decode the rest. Those bytes are the HMAC key.

### Build the signed content

Join three pieces with dots: `{webhook-id}.{webhook-timestamp}.{raw request body}`.

### Compare in constant time

Compute the HMAC-SHA256, base64-encode it, and compare it against the value after `v1,` using a constant-time comparison. Reject the request when it does not match.

## Working examples

**Node.js**

```js
const crypto = require("node:crypto");

const TOLERANCE_SECONDS = 300;

function verifyEmbersWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const header = headers["webhook-signature"];
  if (!id || !timestamp || !header) 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 signedContent = Buffer.concat([
    Buffer.from(`${id}.${timestamp}.`, "utf8"),
    Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8"),
  ]);
  const expected = crypto
    .createHmac("sha256", key)
    .update(signedContent)
    .digest("base64");

  return header.split(" ").some((part) => {
    const [version, value] = part.split(",");
    if (version !== "v1" || !value || value.length !== expected.length) {
      return false;
    }
    return crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}
```

**Python**

```python

TOLERANCE_SECONDS = 300

def verify_embers_webhook(secret: str, headers, raw_body: bytes) -> bool:
    event_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    header = headers.get("webhook-signature")
    if not (event_id and timestamp and header):
        return False

    try:
        age = abs(int(time.time()) - int(timestamp))
    except ValueError:
        return False
    if age > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = b".".join(
        [event_id.encode("utf-8"), timestamp.encode("utf-8"), raw_body]
    )
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode("ascii")

    for part in header.split(" "):
        version, _, value = part.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
```

## Timestamp tolerance and replays

Delivery is at least once, and retries can arrive out of order, so a valid signature on its own is not enough.

- **Bound the timestamp.** Reject a delivery whose `webhook-timestamp` is far from your own clock. The examples above use five minutes, which is a common choice. Pick a window that suits your receiver and keep it small.
- **Deduplicate on `webhook-id`.** It is stable for the event across every attempt and every replay, so store the ids you have processed and drop repeats. Retries after a timeout are the usual reason you see the same event twice.
- **Return quickly.** Any `2xx` counts as success. Acknowledge first, then do slow work in the background.

> **Tip: Test before you go live**
>
> Use **Test** on the endpoint to send a sample. It arrives with the same headers and the same signing path as a real delivery, so your verification code sees a genuine signature while `data.is_test` tells you to ignore the contents.

## When verification fails

- **Every request fails.** You are probably signing the parsed and re-serialised JSON. Use the raw body.
- **It worked, now it does not.** The secret was rotated. Copy the new secret into your receiver, since it is shown only once.
- **Some requests fail.** Check that you are not trimming whitespace or decoding and re-encoding the body anywhere in your framework's middleware.

## Related

- [Signed webhooks](/help/integrations/webhooks/): Create an endpoint, choose its events, rotate its secret.

- [Webhook payload reference](/help/integrations/webhook-payload-reference/): What is inside the body you just verified.

- [Retries and pauses](/help/integrations/webhook-retries-and-pauses/): Why the same event can arrive twice.

## Frequently asked questions

**Can I verify against the parsed JSON instead of the raw body?**

No. The signature covers the exact bytes Embers sent. Re-serialising the JSON changes those bytes and the check will fail.

**What happens if I rotate the signing secret?**

New deliveries are signed with the new secret. Automatic retries of older events use the secret captured when the event was created, while a manual replay uses the current one.
