# Verify webhook signatures

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

> Check the Standard Webhooks headers on every Embers delivery: rebuild the HMAC from the raw body, compare it safely, bound the timestamp, and drop repeats.

Anyone who learns your URL can `POST` JSON to it. The three signature headers on every Embers delivery let your receiver prove a request came from Embers and was not changed on the way.

> **Warning: Solo and above, administrators only**
>
> Signing secrets are created and rotated on the Webhooks page (**Integrations** in the sidebar, then the **Webhooks** row, `/integrations/webhooks`) or under Settings, then API and webhooks, then Webhooks (`/developer/webhooks`), on Solo or above, by a workspace admin. On the Webhooks page, **Rotate secret** is in each endpoint's **...** menu. See [send lead events to your own endpoint](/help/integrations/webhooks/) to create one.

## The headers

Embers follows the Standard Webhooks scheme with HMAC-SHA256:

| Header | What it carries |
| --- | --- |
| `webhook-id` | The event's UUID. Same value as the payload's `id`, and the same on every retry and replay |
| `webhook-timestamp` | Unix seconds when this attempt was sent |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 |

Requests also carry `Content-Type: application/json` and `User-Agent: Embers-Webhooks/1.0`. The body is compact UTF-8 JSON, with no spaces between tokens and non-ASCII characters left unescaped.

## Check a request

### Keep the raw body

Read the request body as raw bytes before any JSON parsing. The signature covers exactly what was sent.

### Decode the secret

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

### Build the signed content

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

### Compare in constant time

Compute the HMAC-SHA256 of the signed content, base64-encode it, and compare it with the value after `v1,` using a constant-time comparison. Treat the header as a space-separated list, as the standard allows, and accept the request if any `v1` entry matches.

## Working examples

Both examples read the secret from the `EMBERS_WEBHOOK_SECRET` environment variable and reject anything that fails.

**Node.js**

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

const TOLERANCE_SECONDS = 300;

function verifyEmbersWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures || !Buffer.isBuffer(rawBody)) 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}.`)
    .update(rawBody)
    .digest();

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

const app = express();

// express.raw keeps req.body as a Buffer of the exact bytes Embers sent.
app.post("/embers", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyEmbersWebhook(process.env.EMBERS_WEBHOOK_SECRET, req.headers, req.body)) {
    return res.status(401).send("invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204);
  // Process `event` here, after acknowledging.
});

app.listen(3000);
```

**Python**

```python
import base64
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

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")
    signatures = headers.get("webhook-signature")
    if not (event_id and timestamp and signatures):
        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 = f"{event_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    )

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode("utf-8"), expected):
            return True
    return False

app = Flask(__name__)

@app.post("/embers")
def embers_webhook():
    # get_data() returns the raw bytes; request.headers is case-insensitive.
    raw_body = request.get_data()
    if not verify_embers_webhook(
        os.environ["EMBERS_WEBHOOK_SECRET"], request.headers, raw_body
    ):
        return "invalid signature", 401
    event = json.loads(raw_body)
    # Hand `event` to a background job, then acknowledge.
    return "", 204
```

## Timestamps, repeats, and speed

Delivery is at least once, and retries can arrive out of order, so a valid signature is not the whole check.

- **Bound the timestamp.** Reject a delivery whose `webhook-timestamp` is far from your clock. The examples allow five minutes. Embers signs each attempt as it sends it, so a retry days later still carries a current timestamp.
- **Deduplicate on `webhook-id`.** It stays the same across every retry and replay of an event. Store the ids you have processed and drop repeats.
- **Answer fast.** Any `2xx` is a success. Embers waits about 10 seconds for your response, and a slower answer counts as a failure that is retried. Acknowledge first, then do slow work in the background. The [retry schedule](/help/integrations/webhook-retries-and-pauses/) shows what happens next.

> **Warning: A rejected request pauses the endpoint**
>
> Returning `401` or `403` pauses the endpoint after one attempt. Returning `400` fails that event only, but five in a row pause the endpoint too. That is harmless against a forged request, but a bug in your check pauses real delivery. Send a sample with **Send test** (**Test** under Settings, then API and webhooks) before going live: it has a real signature and `data.is_test` set to `true`.

## Rotating the secret

**Rotate secret** issues a new secret and shows it once. New events are signed with it immediately, but automatic retries of events created before the rotation keep the old secret, for up to about 6.3 days. Accept both secrets until those retries finish, or replay them: a manual replay uses the current secret.

## Troubleshooting

**Every request fails the check.** You are hashing parsed and re-serialised JSON. Hash the raw body as received.

**It worked, then every request failed.** Someone rotated the secret. Copy the new one into your receiver, since it is shown only once.

**Only requests with accented names fail.** Middleware is decoding and re-encoding the body. Hash the bytes before any transformation.

**The endpoint paused during setup.** Your receiver rejected a sample with a `4xx`. Fix the check, then follow [a webhook endpoint is paused](/help/troubleshooting/webhook-endpoint-paused/) to resume it.

## Related

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

- [Consume Embers webhooks in n8n](/help/integrations/n8n/): The same check inside an n8n Code node.

- [A webhook endpoint is paused](/help/troubleshooting/webhook-endpoint-paused/): Resume delivery after a failed check.

## Frequently asked questions

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

No. The signature covers the exact bytes Embers sent. Parsing and re-serialising the JSON changes those bytes, so the check fails.

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

New deliveries are signed with the new secret at once. Automatic retries of events created before the rotation keep the old secret, and a manual replay uses the new one.

**Does a retry arrive with an old timestamp?**

No. Embers signs each attempt when it sends it, so webhook-timestamp is always the time of that attempt, even for a retry days later.
