Verifying webhook signatures without getting it subtly wrong
An endpoint that accepts data from another service and acts on it is a public endpoint. Anyone who finds the URL can send it a request. The signature is the only thing that says the sender is who they claim to be.
The scheme most providers use
The sender computes an HMAC (typically SHA-256) over a timestamp and the request body with a secret you share, and puts the result in a header. You compute the same value and compare. We wrote and ran a small verifier for this exact pattern: it accepted an untouched request, rejected one with a single extra byte in the body, and rejected a correct signature whose timestamp was more than the allowed tolerance old.

Where verification goes wrong
- Signing the parsed body. The signature covers the exact bytes that were sent. If a framework parses the JSON and you serialise it again, whitespace, key order or number formatting can change, and the check fails, or worse, someone "fixes" it by loosening the check. Read the raw bytes (
request.bodyin Django, the raw buffer in Express) before any parsing. In our test, re-serialising the JSON with default separators did not reproduce the original bytes. - Using
==for the comparison. Use a constant-time comparison such ashmac.compare_digest. - No timestamp check. Without it, a captured request can be replayed years later. Sign the timestamp together with the body, and reject requests outside a tolerance window (a few minutes).
- Verifying after acting. The order is verify, then parse, then act. A handler that logs, queues or writes to the database first has already been used.
- One secret shared everywhere. Use a separate secret per integration, and support rotating them, with a short period where both are accepted.
Replay, retries and idempotency
Providers retry deliveries that time out, so the same event can arrive more than once even without an attacker. Store the event ID and ignore ones you have processed. Return a 2xx quickly and do heavy work in a background job, because a slow handler causes retries.
Sources that cannot sign
Some senders offer weaker mechanisms. Telegram's Bot API, for example, lets you pass a secret_token when calling setWebhook, and then sends it back in the X-Telegram-Bot-Api-Secret-Token header on every update. It is a shared secret in a header rather than a signature over the body, so compare it in constant time and keep it out of logs. Also serve the endpoint on a hard-to-guess path and only over HTTPS.
Test the failure cases
Write tests for a wrong signature, a modified body, an old timestamp and a repeated event. The happy path always works, and these are the cases that matter.
A widely used concrete format: Stripe's signature header
Stripe's webhook signatures are a useful concrete example of the scheme described above, since the shape shows up in many other providers' implementations too. The Stripe-Signature header carries comma-separated fields, documented as a timestamp and one or more versioned signatures: t=1614556800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd. The signed payload is the timestamp, a literal period, and the raw request body concatenated together, exactly the construction in the code sample above, and the timestamp inside the header is what the tolerance check is measured against, not the time the request arrived at your server (the two are normally close, but treating arrival time as the signed time would be checking the wrong value).
The comma-separated, versioned format also solves the key-rotation problem directly: during a rotation, a provider can send two v1= values signed with the old and new secret in the same header, and a verifier that accepts a match against either currently-valid secret handles the transition without any downtime or coordinated cutover moment.