Verify webhook signatures
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.
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 thewhsec_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
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 FalseTimestamp 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-timestampis 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
2xxcounts 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.
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. 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?
What went wrong?
Thank you. We read every answer.
Still stuck?
Send us the article you were following and what happened. We answer from the same inbox that writes these pages.