For developers
Webhook signatures, done properly
30 July 2026 · 2 min read · TMailr
A webhook is a URL that accepts POSTs from the internet. Anybody who learns the URL can send you whatever they like, so the only thing standing between your system and a made-up event is the signature. Getting it right is not difficult, and there are four specific ways to get it wrong.
Sign the bytes you sent, and verify the bytes you received
The signature is an HMAC over the raw request body. Not over the parsed object, not over a re-serialised version of it. JSON round-trips are not byte-stable: key order, unicode escaping and number formatting can all change, and the signature will not survive it.
In practice this means capturing the raw body before your framework parses it. Most frameworks make this awkward on purpose and every one of them has a way. If verification fails on every request and the algorithm looks right, this is almost always the reason.
Put a timestamp inside the signed material
A signature over the body alone is valid forever. Anybody who captures one request can replay it tomorrow, next month, or ten thousand times in a minute, and every copy verifies perfectly because it is genuinely the message you signed.
The fix is to sign the timestamp together with the body and send both:
x-tmailr-signature: t=1785552028,v1=9f2c... expected = HMAC_SHA256(secret, "1785552028." + rawBody)
The verifier recomputes with the timestamp from the header and then checks that the timestamp is recent. Without the freshness check the timestamp is decoration. Five minutes is a reasonable window; it has to be wide enough for ordinary clock skew and narrow enough to be worth having.
Compare in constant time
The obvious comparison returns as soon as two bytes differ, so how long it takes reveals how much of the prefix was right. That is enough to recover a signature byte by byte given enough attempts. Use the comparison your language provides for the purpose: timingSafeEqual, hmac.compare_digest, hash_equals. Check the lengths first, because some of them throw on a mismatch.
Expect the same event more than once
Delivery is at-least-once, and any honest sender will tell you so. A network timeout after your server committed but before the response arrived looks identical to a failure, and the retry is the correct behaviour. Make the handler idempotent: key on the event or message id and ignore one you have already processed.
This matters most for handlers with side effects. Sending a confirmation email twice is embarrassing; charging twice is not.
The checklist
- Verify before parsing, over the raw body, and reject anything that fails.
- Reject a timestamp outside your freshness window, in both directions.
- Compare with a constant-time function, after checking the lengths.
- Deduplicate on the event id, because retries are normal.
- Return 2xx quickly and do the work asynchronously, or the sender’s timeout becomes your retry storm.
- Keep the secret out of your logs, including the error paths.
More on for developers