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 is what separates a delivery from us out of everything else that arrives, so verify it before you parse the body and before you do any work.
Prerequisites
- Your endpoint's signing secret, shown in the delivery portal
(
GET /api/webhooks/portal) alongside the endpoint. Secrets are per endpoint and per environment. Store them the way you store API keys. - Access to the raw request body bytes. Most frameworks parse JSON before your handler runs, and re-serializing the parsed object will not reproduce the signed bytes.
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, or disable it 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 event id, the same value as id in the envelope |
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 prefixed (whsec_) when displayed. 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
Build signed_payload, HMAC it with the decoded secret, base64 encode the
result, and compare it against each v1, entry using a constant-time
comparison. 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
Check webhook-id against your processed-ids table, store it, and return
2xx. Then do the work asynchronously. See
Webhooks.
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, candidate.split(",", 1)[1])
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.