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.
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:
| 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 thewhsec_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 anyv1entry 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 "", 204Timestamps, 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-timestampis 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
2xxis 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.
Related
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?
What went wrong?
Thank you. We read every answer.
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