You wired up webhook handling for a third-party provider. The provider sends a signature header with each request, and your job is to recompute the HMAC-SHA256 over the payload and compare it against what they sent. Your test passes against the documentation's example payload and example secret. In production, every real webhook gets rejected. What is going on?
This is one of those problems where the algorithm is fine, the library is fine, and your implementation is correct against the spec. The failure is in the messy edge of how the bytes get from their server to your computation. I have walked through this with enough teams now that the failure modes have a stable shape.
The Body You Hashed Is Not the Body They Signed
The most common cause: your web framework parsed the request body before your handler got the chance to hash the raw bytes. Express, Flask, Rails, and most other modern frameworks will helpfully parse JSON request bodies into native objects. When you then re-serialize that object back to JSON for hashing, the output is not byte-identical to what the provider sent.
Whitespace differs. Key order differs. Floating-point representations differ. Any of these changes the hash. Your reserialized JSON might be semantically equivalent, but HMAC operates on bytes, not semantics.
The fix is to capture the raw request body before any parsing middleware touches it. Most frameworks have a mechanism for this; Express has raw-body, Flask has request.get_data(cache=True), Rails has request.raw_post. Use whichever applies and hash that.
Once you have the raw body, you can parse the JSON separately for your application logic. The hashing and the parsing are independent paths on the same bytes.

Photo by Markus Winkler on Pexels
The Encoding of Your Comparison Is Wrong
HMAC-SHA256 produces 32 bytes of output. Providers expose this in different encodings: hex (64 lowercase characters), base64 (44 characters with a = pad), or even raw bytes in a binary header. If you compute hex and compare against a base64 string the provider sent, the comparison will fail even though the underlying digest is correct.
Confirm what encoding the provider uses, encode your local output the same way, and compare strings of equal expected length. A tool that lets you compute HMAC-SHA256 in multiple output formats side by side is useful for this debugging step; you can paste your secret and a test payload and see both the hex and base64 outputs so you know which one to compare against the header value. The free tool at https://evvytools.com/tools/dev-tech/hash-generator/ supports both encodings for HMAC-SHA256 alongside the plain hashing functions.
The Secret You Used Includes Whitespace or Quotes
Copy-pasting a secret from a dashboard sometimes pulls in a trailing newline, a leading space, or escaping quotes. Your code stores the secret with the extra characters, and every HMAC you compute uses a slightly different key than the provider's.
This is hard to spot visually because the offending characters are usually invisible. Inspecting the secret's length is the quickest check; a known-length key (some providers publish the expected length) that comes in too long is your tell. Strip whitespace at the boundary where you load the secret, and compare against the expected length before using it.
The Provider Signs Headers Plus Body, Not Just Body
Some webhook providers include certain request headers in the signed payload. The HMAC input might be timestamp + "." + body, or method + "\n" + path + "\n" + body, or some other concatenation specific to that provider. Recomputing the HMAC over the body alone will not match.
Read the provider's verification documentation carefully. The signed payload format is usually a specific recipe that includes a timestamp to prevent replay attacks. If your code hashes only the body, it will be wrong against any provider that signs timestamp.body.
Stripe's webhook verification documentation and GitHub's webhook documentation both spell out the exact signed payload format. Other providers usually have similar reference pages. Implement against the spec, not against an assumption.
The Timestamp Window Closed
A few providers reject verified signatures if the timestamp in the signed payload is too old. This is anti-replay protection, not a signature problem, but the error message your code receives can look like a signature mismatch.
Check whether the provider returns a more specific error code for stale timestamps versus invalid signatures. If your test webhooks succeed when sent immediately but fail when sent through a debugging proxy that adds latency, the timestamp window is the likely cause.
Building a Reliable Test
The most useful debugging step is a side-by-side comparison: take a real webhook that the provider rejected, log the exact request headers and raw body bytes, then reproduce the HMAC computation in a separate scratch script using the same secret. If the scratch computation matches the provider's expected signature, the failure is in how your production code captures or processes the request. If the scratch computation does not match, you have a recipe or encoding bug to fix in isolation.
Wikipedia's HMAC article covers the algorithm at the level of what the input and output bytes actually are, which helps when you need to verify your library's behavior matches the spec. OWASP maintains a cheat sheet on cryptographic verification practices that includes guidance on signature verification.
The broader walkthrough on file integrity verification, which uses the same hashing primitives in a different context, is in the longer guide on verifying downloaded file checksums. The mental model translates between request signing and file verification; in both cases the core question is whether the bytes you have match the bytes someone authoritative computed a hash over.
The Pattern
When HMAC verification fails in production but works in tests, the bug is almost always in how the bytes get to the hashing function rather than the hashing function itself. Inspect the raw bytes, confirm the encoding of your comparison, audit the secret for invisible characters, and check whether the signed payload format includes anything besides the body. One of those four explains the vast majority of cases.
A correct implementation matches reality byte for byte. Any place where your code transforms the bytes between receipt and hashing is a place where the verification might silently break.
A Reproducer Template Worth Saving
Once you have debugged this once, save a scratch script that demonstrates the working case end to end. Mine is about thirty lines of Python: it loads a sample raw body from a file, loads a sample secret from an environment variable, computes HMAC-SHA256 in the encoding the provider documents, and prints both the input bytes (in hex) and the output signature. Side-by-side comparison of my scratch output against the provider's expected output catches every byte-level mismatch immediately.
When a future webhook starts failing, I run the scratch script against a captured payload and compare against the signature header. If the scratch script matches the captured header, my application code has a bytes-handling bug. If the scratch script does not match, the provider has changed something in their signing format or the secret has rotated.
The scratch script is the kind of thing every team should have for every webhook provider they integrate with. The setup cost is one afternoon; the savings is hours per future incident.
What To Log During Verification
Logging the raw body length, the signature header value, and the computed signature value in your verification path makes future debugging much faster. Avoid logging the secret itself or the entire raw body in production logs; both are sensitive in different ways. But the length of the body and the value of the signature header are safe to log and are exactly what you need when chasing a verification mismatch.
A small structured log message at every verification failure that includes those three values turns "the webhooks are failing" into a quickly diagnosable problem. The information is in the data you already have; you just need to surface it where the on-call engineer can see it.
The Underlying Pattern
HMAC verification is a stable algorithm. When it fails, the cause is almost always in how the inputs get to the algorithm. Logging the inputs, comparing against a known-good reproducer, and walking through the candidate causes in order resolves the vast majority of cases in under thirty minutes. The pattern is the same across every provider that uses HMAC for webhook signing.











