Webhook Signature Verification Across Major SaaS Platforms
HMAC-SHA256 dominates webhook signing, but implementation details vary dangerously across platforms.

HMAC, Hash-based Message Authentication Code, is the dominant algorithm across the webhook ecosystem. Stripe, GitHub, Shopify, Slack, and most other major providers all use it. HMAC-SHA256 has become the de facto standard for defensible reasons: SHA-256 remains unbroken as of 2025, it requires only one shared secret rather than a public/private key pair, and every major language has a correct, well-tested implementation available. MD5 and SHA-1 are cryptographically broken. Any platform still defaulting to them is operating below the current acceptable baseline, and Twilio's continued use of SHA-1 is the most prominent example of that problem surviving in production at scale.
The generic verification flow is consistent across every HMAC-based provider: capture the raw request body before any parsing, extract the provider's signature from its designated header, recompute the HMAC-SHA256 digest using the shared secret plus whatever provider-specific additions the platform folds in, and compare using constant-time comparison. Never a plain equality operator. Optionally, check an embedded timestamp to reject stale replays.
Implementations diverge across three axes: the header name, what actually gets signed (raw body only, a composite string with a timestamp, or even the URL and its parameters), and how the digest is encoded in the header (hexadecimal versus Base64). Two platforms break from the HMAC pattern entirely. Discord uses Ed25519 asymmetric cryptography; SendGrid relies on IP allowlisting with no cryptographic signature at all. The verification code you write for Stripe will not transfer to Discord, and the security model you build for Stripe has no meaningful application to SendGrid.
Stripe: composite signed strings, embedded timestamps, and the raw-body requirement
Stripe delivers its signature in the Stripe-Signature header, formatted as t=<timestamp>,v1=<digest>. That format is already telling you something: Stripe does not sign the raw body alone. It signs a composite string constructed as timestamp + "." + raw_body. The timestamp is folded directly into the signed payload, which means an attacker cannot modify it without invalidating the signature. Replay protection is built into the signing scheme itself, not bolted on afterward.
Stripe's SDK libraries default to a five-minute tolerance window. A cryptographically valid signature attached to a payload older than 300 seconds gets rejected. You can widen that window in high-latency environments, but you are explicitly trading security for resilience when you do.
The raw-body requirement is where most Stripe integrations break first, and it is almost always an invisible failure. Verification must operate on the exact bytes received, before any JSON parsing. If your framework automatically parses the incoming JSON and you re-serialize it to run verification, the whitespace changes, key ordering changes, Unicode escaping changes, and the digest will not match even when your secret is correct and the payload is genuine. The fix is framework-level configuration, capturing raw bytes before any middleware touches the body. You cannot patch this in application logic after the fact.
Stripe's documentation explicitly recommends using the SDK methods rather than hand-rolling HMAC. stripe.Webhook.construct_event in Python, stripe.webhooks.constructEvent in Node.js. The SDKs handle constant-time comparison, timestamp tolerance, and header parsing. Each Stripe endpoint carries its own signing secret, distinct from your API key. Treat it accordingly.
GitHub: straightforward HMAC-SHA256, no timestamp, and the deprecation of SHA-1
GitHub's implementation is the most straightforward of the major platforms. The signature lives in X-Hub-Signature-256, formatted as the literal string sha256= followed by a lowercase hexadecimal digest. What GitHub signs is simply the raw request body: no timestamp, no URL, no composite string construction.
There is a legacy header, X-Hub-Signature, that uses SHA-1. GitHub deprecated SHA-1 signing in 2022. Always use the -256 variant. Reading the wrong header and then attempting to verify against HMAC-SHA256 produces a mismatch with no obvious diagnostic trail.
Because GitHub does not embed a timestamp, there is no built-in replay protection. If your use case requires it, you handle idempotency at the application layer. GitHub provides an X-GitHub-Delivery GUID with each request; tracking that GUID is the standard approach to detecting replayed events.
Use constant-time comparison. secure_compare, crypto.timingSafeEqual, whatever your language provides. Timing attacks against naive string comparison are a real exploit class, not a theoretical concern, and GitHub's documentation says this explicitly.
Shopify: identical algorithm to GitHub, but Base64 encoding instead of hex
Shopify uses HMAC-SHA256 over the raw request body, exactly like GitHub. The header is X-Shopify-Hmac-Sha256. What gets signed is identical. The shared secret model is identical. The only difference is that Shopify encodes the digest in Base64, not hexadecimal. The underlying bytes are the same; the string representations are entirely incompatible, and that single distinction causes consistent verification failures when developers carry a GitHub implementation directly into a Shopify integration without checking the encoding specification.
Shopify does not embed a timestamp in the signature header, so there is no built-in replay protection. There is also a practical operational hazard during secret rotation: after you rotate the Shopify client secret, propagation can take up to an hour. Your verification logic needs to tolerate both the old and the new secret during that window, or you will reject legitimate events before Shopify has fully switched over.
Slack: composite string with version prefix and a 5-minute staleness window
Slack signs a composite string rather than the raw body alone, similar in structure to Stripe's approach, but the construction differs. The signature arrives in X-Slack-Signature; the timestamp arrives separately in X-Slack-Request-Timestamp. What Slack signs is v0: concatenated with the timestamp, concatenated with :, concatenated with the raw body. The v0 is a literal version prefix baked into the signed content. The resulting HMAC-SHA256 digest is hex-encoded, and the header carries a v0= prefix.
Replay protection mirrors Stripe's: Slack rejects requests whose X-Slack-Request-Timestamp is older than five minutes. Your server's clock needs to be NTP-synchronized. Clock skew causes you to reject legitimate events from Slack, and diagnosing that in production takes longer than it should.
The most common construction error is getting the composite string wrong. The format is precisely v0:timestamp:body. An extra space, a different separator, or reversed field order produces an incorrect digest, verification returns false, and nothing tells you that you used a period instead of a colon.
Slack's signed secrets replaced an older verification token scheme that relied on simple static token comparison, a fundamentally weaker model. If you are maintaining a legacy Slack integration, the migration is a different verification architecture, not a minor update.
Twilio: HMAC-SHA1, URL-based signing, and why the exact callback URL matters
Twilio uses HMAC-SHA1, delivered in X-Twilio-Signature. SHA-1 is cryptographically broken for collision resistance. HMAC-SHA1 is not yet practically broken for authentication specifically, but teams should understand they are operating on a weaker baseline than every other major provider in this list and watch for whether Twilio introduces SHA-256 support.
What Twilio signs is more unusual than any other platform here: the full callback URL concatenated with sorted POST parameter key-value pairs. Not the raw HTTP body in the conventional sense. The implication is consequential. If your application sits behind a proxy that strips HTTPS and rewrites the scheme to HTTP, or behind a load balancer that rewrites the host header, the URL your application reconstructs during verification will not match the URL Twilio signed, and verification will fail. Every character in the reconstructed URL must match what Twilio has on file. This is the kind of failure that works in your local environment and breaks silently after a single infrastructure change.
There is no embedded timestamp. Replay protection, if you need it, is entirely your problem.
Discord and SendGrid: where the HMAC pattern breaks down entirely
Discord made a deliberate choice to use asymmetric cryptography rather than HMAC. It uses Ed25519. The signature arrives in X-Signature-Ed25519; the timestamp arrives in X-Signature-Timestamp. What Discord signs is the timestamp concatenated with the raw body, signed with Discord's private key. Verification uses the application's public key from the Discord developer portal.
There is no shared secret to store, rotate, or accidentally commit to a repository. The public key is public by design. Verification code is categorically different from anything you write for an HMAC platform: different library, different conceptual model, different failure modes. Nothing from your Stripe or GitHub implementation carries over.
SendGrid sits in a different category entirely. Its primary security model is IP allowlisting: you restrict inbound traffic to SendGrid's published IP ranges. There is no cryptographic signature, no shared secret, no header to verify. This is a structurally weaker baseline than any HMAC-based approach. IP spoofing is a real attack vector, and SendGrid operates on shared egress infrastructure, meaning its IP ranges also belong to other tenants, which reduces the reliability of IP-based trust considerably. SendGrid endpoints require ongoing maintenance as those ranges change. Teams should account for this explicitly and avoid treating it as equivalent to the platforms that do cryptographic verification.
The differences that cause the most integration failures in practice
Raw-body corruption is the most common failure across every HMAC-based platform. Parsing JSON and then re-serializing it changes whitespace, key ordering, and Unicode escaping. The bytes no longer match what the provider signed, verification fails, and the error looks identical to a wrong secret. You cannot fix this in application logic; it requires framework-level configuration that captures raw bytes before any middleware transforms them.
Hex-versus-Base64 encoding mismatch is the second most reliable failure point for teams integrating multiple platforms. GitHub outputs hex; Shopify outputs Base64. Square also uses Base64; HubSpot uses hex. A shared verification function will silently fail on whichever encoding it was not written for. The algorithm being identical across providers does not make the output interchangeable.
Composite string construction errors are silent and specific. Stripe signs timestamp + "." + body. Slack signs "v0:" + timestamp + ":" + body. Twilio signs the URL plus sorted parameters with no conventional body bytes at all. Getting the separator wrong, reversing field order, or omitting the version prefix produces an incorrect digest, and the only signal you receive is that verification returned false.
Using the wrong header is a GitHub-specific trap. X-Hub-Signature uses the deprecated SHA-1 algorithm; X-Hub-Signature-256 uses SHA-256. Reading the SHA-1 header and computing HMAC-SHA256 against it produces a mismatch that has nothing to do with your secret or your body handling, and the error gives you no indication of where the divergence actually is.
Timing-unsafe string comparison does not cause visible integration problems during development. Plain equality operators leak information about where two strings first diverge, and that information is exploitable. Use your language's constant-time comparison function. This is not optional hardening; it is the baseline.


