How to verify webhook signatures
6 min readThe Eventeer team
A webhook endpoint is a public URL that accepts POST requests. Anyone who finds it can send it a body that claims to be “payment succeeded”. The standard defense is a signature: the sender computes a keyed hash of the request with a secret you both know, and you recompute it and compare. This post explains how that works, where people go wrong, and what the common providers actually send.
If you only want to check one signature by hand, there is a free webhook signature verifier that runs in your browser.
The idea in four lines
- You and the sender share a secret. Nobody else has it.
- The sender computes
HMAC-SHA256(secret, message)and puts the result in a header. - You compute the same thing from the request you received.
- If the two values match, the sender knew the secret and the message was not changed.
An HMAC does not hide the body. It proves who sent it and that it arrived unmodified. The details that matter are what “message” means, how you compare, and how you stop a valid request from being replayed.
Rule 1: sign the raw body
The message is the exact bytes the sender signed. If your framework parses the JSON and you re-serialize it, key order, whitespace and number formatting can change, and the signature will not match. Capture the raw body before any parser touches it.
In Express that means express.raw({ type: "application/json" }) on the webhook route instead of express.json(). In Rails, request.raw_post. In Phoenix, a custom body reader that keeps the original bytes. In Flask, request.get_data().
This is the most common failure: the code is correct, the secret is correct, and verification fails because middleware already parsed the body.
Rule 2: compare in constant time
An ordinary string comparison returns as soon as it finds a differing byte. In theory an attacker can measure that and guess a signature byte by byte. Use the comparison function your platform provides for this: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, Plug.Crypto.secure_compare in Elixir, hash_equals in PHP. Both inputs must have equal length, so check the length first (or decode both to bytes of fixed size).
Rule 3: check the timestamp
A signature proves a request was genuine once. It does not stop someone who captured it from sending it again an hour later. Providers fix this by including a timestamp in the signed message. You reject requests whose timestamp is too far from your clock. Five minutes in either direction is the usual tolerance. For a stricter guarantee, also remember recent signatures or event ids and reject repeats.
Not every provider signs a timestamp. GitHub does not, so replay protection there means deduplicating on the delivery id.
What each provider sends
The formats differ in small ways that break copy-pasted code. These are the ones people ask about most; check each provider’s docs for the current details before shipping.
| Provider | Header | Signed message | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature: t=…,v1=… |
t + "." + raw body |
hex |
| GitHub | X-Hub-Signature-256: sha256=… |
raw body | hex |
| Shopify | X-Shopify-Hmac-Sha256 |
raw body | base64 |
| Standard Webhooks | webhook-signature: v1,… with webhook-id and webhook-timestamp |
id + "." + timestamp + "." + raw body |
base64 |
| Eventeer | X-Eventeer-Signature: t=…,v1=… |
t + "." + raw body |
hex |
Notes on the table:
- Stripe may include several
v1values in the header, for example while a secret is being rotated. Accept the request if any of them matches. The signing secret starts withwhsec_and is used as given. - GitHub has no timestamp. Verify the HMAC and deduplicate on the
X-GitHub-Deliveryid. OlderX-Hub-Signature(SHA-1) headers exist but you should use the SHA-256 one. - Shopify signs only the body, with your app’s secret, and base64-encodes the result. A hex comparison against it will never match.
- Standard Webhooks is a specification several services follow. The signature header can hold several space-separated
v1,<base64>entries. The secret is conventionally prefixedwhsec_, and the part after the prefix is base64 that you decode to get the key bytes.
A verifier in Node
Here is a complete check for a Stripe-style header, which is also the format Eventeer uses:
import crypto from "node:crypto";
export function verify(secret, rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // too old or too new
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// The header can carry several v1 values (secret rotation): any match passes.
return header
.split(",")
.map((p) => p.trim())
.filter((p) => p.startsWith("v1="))
.some((p) => {
const got = Buffer.from(p.slice(3), "hex");
const want = Buffer.from(expected, "hex");
return got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
rawBody is the string or buffer of the unparsed request. Call it before you do anything else with the payload, and return 401 when it is false.
A Python version is the same shape:
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
items = [p.strip().split("=", 1) for p in header.split(",")]
t = next((v for k, v in items if k == "t"), None)
if t is None or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(v, expected) for k, v in items if k == "v1")
How Eventeer signs its own deliveries
When Eventeer sends an event to a destination, the request carries X-Eventeer-Signature in the format t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the destination’s signing secret. Reject any request whose t is more than five minutes from now. The headers X-Eventeer-Event-Id (use it to drop duplicates) and User-Agent: Eventeer-Webhooks/1 come with it. The Node function above verifies these requests as written.
The signing secret is returned once when you create the destination:
curl -X POST https://app.eventeer.io/api/v1/destinations \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"billing hook","url":"https://example.com/hooks/eventeer"}'
The response contains signing_secret. To replace it, call POST /api/v1/destinations/:key/rotate-secret; the old secret stops working at once, so update your endpoint immediately after.
The scheme also works in the other direction. An hmac webhook listener verifies X-Eventeer-Signature on incoming requests, so a sender you control can sign with the same recipe. When the request has an Idempotency-Key, the sender should add a v2 value that also covers the key, which stops a captured request from being replayed with a different key. A signature that was accepted once is remembered until it can no longer pass the timestamp check, so a second use of the same signed request is answered with 401. Sign each request with a fresh timestamp or body, and retry with an Idempotency-Key if you need to resend.
If a provider signs its webhooks and you need to check them before they reach your code, an hmac listener is the place. For storing and forwarding signed payloads without losing them, see replaying failed webhooks.
A short checklist
- Read the raw body before parsing.
- Compute the HMAC with the exact message format for that provider.
- Compare with a constant-time function, after matching lengths.
- Enforce a timestamp tolerance where the provider signs one.
- Deduplicate on an event or delivery id.
- Store secrets in a secrets manager, rotate them, and accept two during the switch.
- Return
401on failure and log the reason, but not the secret or the full signature.
The webhooks use case page shows how listeners and destinations fit together, and the API docs list every endpoint named here.