Skip to content

Verify webhook signatures

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

Updated 2 min read Solo and above

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.

The headers

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

HeaderWhat it is
webhook-idA stable UUID for the event. Use it for idempotency
webhook-timestampUnix seconds for this delivery attempt
webhook-signaturev1, followed by the base64 HMAC-SHA256

How to verify

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

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

  3. Build the signed content

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

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

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));
  });
}
import base64
import hashlib
import hmac
import time

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.

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.

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.

Was this article helpful?

Still stuck?

Send us the article you were following and what happened. We answer from the same inbox that writes these pages.

Email support

to move to open esc to close