Skip to content

Verify webhook signatures

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.

Solo and above Updated 3 min read

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.

The headers

Embers follows the Standard Webhooks scheme with HMAC-SHA256:

HeaderWhat it carries
webhook-idThe eventโ€™s UUID. Same value as the payloadโ€™s id, and the same on every retry and replay
webhook-timestampUnix seconds when this attempt was sent
webhook-signaturev1, 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

  1. Keep the raw body

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

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

  3. Build the signed content

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

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

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);
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 shows what happens next.

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 to resume it.

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.

Was this article helpful?

Your first 2,000 engagers free

Every day Embers reads the public activity around your market, ranks the people who fit, and hands you a short list with the reason each one surfaced. You decide who to contact.

Start free trial
  • No card to start
  • No LinkedIn login
  • Cancel anytime

↑↓ to move ↵ to open esc to close