Verifying signatures
Authenticate every delivery against the raw body before you parse it, and reject replays.
Your webhook URL is a public HTTPS endpoint. Anyone who learns it can POST to it. The signature proves a request came from us, so verify it before you parse the body and before you do any work.
Prerequisites
- Your endpoint's signing secret, the
secretreturned once byPOST /api/webhookswhen you registered the endpoint. Secrets are per endpoint and per environment. Store them the way you store API keys. - Access to the raw request body bytes, before any JSON middleware parses them.
Verify against the raw bytes. Whitespace, key order, and unicode escaping are all part of what was signed, and any of them can differ after a parse-and-reserialize round trip. Capture the body before the JSON middleware runs, or disable the middleware for this route.
The scheme
Deliveries are signed with a Standard Webhooks compatible HMAC scheme. Three headers ride each delivery:
| Header | Contents |
|---|---|
webhook-id | The id the delivery was published under. It equals the envelope id, except on a manual replay |
webhook-timestamp | Unix seconds at signing time |
webhook-signature | One or more space-separated signatures, each v1,<base64> |
The signed content is the three parts joined by periods:
signed_payload = "{webhook-id}.{webhook-timestamp}.{raw_body}"
expected = base64(HMAC_SHA256(secret_bytes, signed_payload))
The secret is displayed with a whsec_ prefix. Strip the prefix and
base64-decode the remainder to get the key bytes.
webhook-signature can carry several signatures, space separated. That
is how secret rotation works without dropped deliveries: accept the
delivery if any of them matches. Compare with a constant-time
comparison, never == on strings.
Verifying
Reject stale timestamps
Require webhook-timestamp to be within a tolerance window of your own
clock. Five minutes is typical. Without this check, a captured delivery can
be replayed forever, because the signature over it stays valid.
Recompute and compare
Compute expected as above and compare it against each v1, entry in
constant time. Any match accepts the delivery.
Only then parse
Parse the JSON after verification passes, not before. A payload that failed verification should never reach your business logic, your logs at info level, or your queue.
Deduplicate and acknowledge
Deduplicate on the envelope id, not on webhook-id: a replay is
published under a new delivery id so that it is not dropped, while the
envelope id stays the same. Store the payload, return 2xx, then do the
work asynchronously, as described in
Processing.
Reference implementation
import base64, hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
"""Return True when the delivery is authentic and fresh."""
event_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signatures = headers["webhook-signature"].split(" ")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{event_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected.encode(), candidate[3:].encode())
for candidate in signatures
if candidate.startswith("v1,")
)
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(
rawBody: Buffer,
headers: Record<string, string>,
secret: string,
): boolean {
const eventId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"].split(" ");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = Buffer.concat([
Buffer.from(`${eventId}.${timestamp}.`),
rawBody,
]);
const expected = createHmac("sha256", key).update(signed).digest();
return signatures.some((candidate) => {
if (!candidate.startsWith("v1,")) return false;
const given = Buffer.from(candidate.slice(3), "base64");
return (
given.length === expected.length && timingSafeEqual(given, expected)
);
});
}
What to do on failure
Return a 4xx and log it with the webhook-id. Do not process the payload,
and do not return 2xx to make the retries stop: a genuine delivery that
fails verification means your secret is stale or your body handling is
wrong, and suppressing the retry hides both.
A burst of verification failures you did not cause is worth an alert.