Why GitHub, Stripe, and Slack Calculate Webhook Signatures Differently
One of the most common headaches when building a webhook receiver is: "the secret key is correct, but signature verification keeps failing." Trace the cause, and it's usually someone copying another platform's verification code and using it as-is. GitHub, Stripe, and Slack all use a mechanism with the same name — "HMAC-SHA256 signature" — but the actual string each one hashes is different, so copying the code verbatim means verification fails every single time, even with a 100% correct secret. This guide walks through why and how the three platforms each construct their signatures differently, starting from first principles.
1. Why Webhooks Need Signatures: Confirming the Sender and Detecting Tampering
A webhook is just an external server firing an HTTP POST at your endpoint — anyone who knows the URL can send a fake request. To prevent that, platforms hash the request body (plus some extra data) with HMAC-SHA256 using a secret key that only the sender and receiver know, and attach the result in a header. The receiving server hashes the same string with the same secret and compares the result against the signature it received. If they match, you can trust that "this request genuinely came from that platform and wasn't tampered with in transit." Up to this point, all three platforms share the same principle — what differs is exactly what gets hashed.
2. GitHub: Hashes the Body As-Is
GitHub uses the simplest structure of the three. It hashes the raw request body (the JSON payload) as-is with HMAC-SHA256, prefixes the result with sha256=, and sends it in the X-Hub-Signature-256 header. Extra elements like a timestamp are not part of what gets signed.
3. Stripe: Adds a Timestamp to Block Replay Attacks
Stripe adds one more layer. It hashes a string formed as {current-unix-timestamp}.{body} — timestamp and body joined by a period — and packages the result as t=timestamp,v1=HEX in the Stripe-Signature header. The reason the timestamp is part of what gets signed is to prevent replay attacks. If only the body were hashed, an attacker who intercepted a genuine past request (signature included) could resend it unchanged and the signature would still validate. With the timestamp baked into the signature, the receiving server can reject a request whose timestamp is too old.
4. Slack: A Three-Part Structure That Also Includes a Version Prefix
Slack goes one step further, hashing a string of the form v0:{timestamp}:{body} — a version prefix, timestamp, and body joined by colons. The result is sent as v0=HEX in the X-Slack-Signature header, with the timestamp used carried separately in the X-Slack-Request-Timestamp header. The reason for including a version prefix (v0) is to let future signature schemes (v1, v2, and so on) be distinguished while maintaining backward compatibility.
5. Comparing the Three Approaches Side by Side
| Platform | String hashed | Result format | Header |
|---|---|---|---|
| GitHub | Raw body | sha256=HEX | X-Hub-Signature-256 |
| Stripe | {timestamp}.{body} | t=timestamp,v1=HEX | Stripe-Signature |
| Slack | v0:{timestamp}:{body} | v0=HEX | X-Slack-Signature (+ separate timestamp header) |
timestamp + '.' + body) and use it as-is on a GitHub webhook, and it breaks — GitHub never includes a timestamp in its signature to begin with, so the hash input itself is different, and the resulting hash will never match. This isn't a secret-management mistake — it's a structural mistake of confusing exactly which string got hashed.
6. Comparing the Three Formats Directly with a Tool
The Webhook Generator mirrors this exact difference at the code level. Select the GitHub template, and it hashes the whole body as-is with HMAC-SHA256 and outputs it as sha256=. Select Stripe, and it generates the current Unix timestamp, hashes the string {timestamp}.{body}, and outputs t=timestamp,v1=HEX. Select Slack, and it hashes the string v0:{timestamp}:{body} and shows both v0=HEX and the timestamp value used. All three signatures are computed entirely with the browser's Web Crypto API (crypto.subtle) and never sent over the network, so you can safely test whether your receiving server's verification logic reproduces the correct format for whichever platform you're targeting.
Frequently Asked Questions
Q. Why doesn't GitHub include a timestamp in its signature?
A. GitHub's webhook events generally rely on a separate mechanism — a delivery ID — to prevent duplicate processing, which appears to be why the signature design doesn't force a timestamp into the mix. That said, if you need replay protection, you'll need to implement delivery-ID deduplication yourself on the receiving server.
Q. What's the allowed timestamp tolerance for Stripe and Slack?
A. Recommended values vary by platform, but a common convention is to have the receiving server reject a request if the gap between the current time and the request's timestamp exceeds some window (e.g., five minutes). For the exact recommended value, it's safest to check each platform's latest official documentation.
Q. If I'm designing my own custom webhook signature, which approach should I follow?
A. If you need replay-attack protection, it's safer to include a timestamp in the signed content, the way Stripe and Slack do. If there's a chance you'll want to change your signature scheme later, adding a version prefix like Slack's makes it easier to maintain backward compatibility down the road.
Q. Is it fine to compare the HMAC strings with a plain === check?
A. It's not recommended for security. A plain string comparison can be vulnerable to timing attacks, so it's safer to use a constant-time comparison function, like Node.js's crypto.timingSafeEqual. Most platforms' official SDKs already build this comparison logic in for you.