Skip to content

Verifying signatures ​

Every delivery is signed. Verify the signature before you trust the body — the endpoint URL is not a secret, and anyone who learns it can post to it.

The scheme is Standard Webhooks, so an off-the-shelf library will work. It is also simple enough to implement directly, which is what this page describes.

Implementation: src/lib/utils/webhook/signature.util.ts.

The algorithm ​

1. Take the secret. It looks like whsec_hZ3…. Strip the whsec_ prefix and base64-decode the remainder. Those bytes are the HMAC key.

This step is the one people skip. The key is the decoded bytes, not the printable string.

2. Build the signed content, joining three parts with full stops:

{webhook-id}.{webhook-timestamp}.{raw request body}

webhook-id and webhook-timestamp are the header values, verbatim. The body is the raw bytes as received — before any JSON parsing, and with no reserialisation, reindentation or key reordering.

3. HMAC-SHA256 the content with the key, and base64-encode the digest.

4. Compare. The webhook-signature header holds one or more entries separated by a single space, each of the form v1,<base64 digest>:

webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9wZQ==

The delivery is authentic if any entry matches. Compare in constant time.

Why the header carries a list ​

Secret rotation. For 24 hours after a rotation, deliveries are signed with both the old and the new secret, so your receiver keeps working while you redeploy at your own pace.

A verifier that reads only the first entry will break during that window. Split on the space and try each one.

Ignore entries whose version prefix you do not recognise — v1 is HMAC-SHA256 today, and a future asymmetric scheme would use a different prefix alongside it, not instead of it.

Checking the timestamp ​

Reject deliveries whose webhook-timestamp is far from your clock. Five minutes either way is the usual tolerance. Without this check, an attacker who captures one valid delivery can replay it indefinitely.

webhook-timestamp is Unix time in seconds, and it is the time of this attempt — not of the event. That is deliberate and it matters here: a delivery retried three days later carries a fresh timestamp, so a strict tolerance will not reject legitimate retries. If you need the event time, read the x-event-time header or event.timestamp in the body.

It is also why a signature cannot be computed once and cached: every attempt is signed anew.

Node.js ​

js
import crypto from "node:crypto";

const PREFIX = "whsec_";
const TOLERANCE_SEC = 5 * 60;

export function verify(rawBody, headers, secrets) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const header = headers["webhook-signature"];
  if (!id || !timestamp || !header) return false;

  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SEC) return false;

  const content = `${id}.${timestamp}.${rawBody}`;

  // `secrets` is an array so you can accept the old and new one during a rotation.
  const expected = secrets.map((secret) => {
    const key = Buffer.from(
      secret.startsWith(PREFIX) ? secret.slice(PREFIX.length) : secret,
      "base64",
    );
    return crypto.createHmac("sha256", key).update(content).digest();
  });

  return header.split(" ").some((entry) => {
    const [version, digest] = entry.split(",");
    if (version !== "v1" || !digest) return false;
    const provided = Buffer.from(digest, "base64");
    return expected.some(
      (want) =>
        want.length === provided.length &&
        crypto.timingSafeEqual(want, provided),
    );
  });
}

rawBody must be the raw string or buffer. In Express, that means express.raw({ type: "application/json" }) on this route — express.json() gives you a parsed object, and re-stringifying it will not reproduce the bytes we signed.

Python ​

python
import base64, hashlib, hmac, time

PREFIX = "whsec_"
TOLERANCE_SEC = 5 * 60


def verify(raw_body: bytes, headers: dict, secrets: list[str]) -> bool:
    id_ = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    header = headers.get("webhook-signature")
    if not (id_ and timestamp and header):
        return False

    try:
        if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SEC:
            return False
    except ValueError:
        return False

    content = f"{id_}.{timestamp}.".encode() + raw_body

    expected = [
        hmac.new(
            base64.b64decode(s[len(PREFIX):] if s.startswith(PREFIX) else s),
            content,
            hashlib.sha256,
        ).digest()
        for s in secrets
    ]

    for entry in header.split(" "):
        version, _, digest = entry.partition(",")
        if version != "v1" or not digest:
            continue
        try:
            provided = base64.b64decode(digest)
        except Exception:
            continue
        if any(hmac.compare_digest(want, provided) for want in expected):
            return True
    return False

Common causes of a failed verification ​

SymptomCause
Every delivery failsThe secret was used as a string instead of base64-decoded bytes.
Every delivery failsThe body was parsed and re-serialised before signing.
Works locally, fails behind a proxyA middleware rewrote or re-encoded the body.
Fails only after a rotationThe verifier reads one signature instead of splitting on spaces.
Intermittent failuresServer clock drift against a tight tolerance.
Fails on non-ASCII namesThe content was encoded as something other than UTF-8.

If verification fails, log the webhook-id — it is in the delivery log on our side too, which makes a support conversation a lookup rather than a guess.

Storing the secret ​

Treat it like a password. The whsec_ prefix exists so a leaked secret is greppable in your codebase and recognisable to a secret scanner — if one turns up in a commit, rotate it in the dashboard and rely on the 24-hour grace period to redeploy.