When webhook verification fails, check which endpoint secret you loaded, then check what body reached the verifier. Do not disable verification to make the delivery succeed. A parsed JSON object can look correct while losing the exact representation that was signed.
This guide separates four questions: the secret, the body, the provider’s signature scheme, and replay handling. It is for developers receiving signed HTTP events, not a replacement for a provider’s maintained verifier. The local test described below uses invented data locally; it does not validate a deployed integration.
Does the secret belong to this delivery path?
Start by identifying the configured integration and environment. Do not select an arbitrary secret from unverified payload fields. Check the endpoint configuration through your trusted deployment records.
Stripe distinguishes the signing secret for CLI-forwarded events from the secret for a Dashboard-managed endpoint. Its verifier also needs the received body and signature header; a failure can originate in any of these inputs. With Express, its current guide places the webhook route before the general JSON middleware. Stripe troubleshooting
A useful diagnostic record contains the deployment version, endpoint identifier, secret configuration version or alias, header-presence flag, and verifier error category. Keep secrets and customer payloads out of shared logs. You need to identify which configuration was used, not paste its value into a support thread.
Why can equivalent JSON fail verification?
Consider these two fictional request bodies:
{"event":"demo", "count":1}
{"event":"demo","count":1}
They parse to the same object. Their byte sequences differ by one space. If a signature was computed over the first body, verifying a newly serialized second body checks a different message.
Our local Node.js fixture used a deliberately non-secret test key and HMAC-SHA256. It checked that:
- The unchanged body produced the same digest on two computations.
- Parsing and serializing the spaced body changed the bytes and digest, while preserving the parsed value.
- Adding a trailing newline changed the digest.
- An actual Unicode character and its JSON escape parsed equivalently but produced different digests.
- A different test key changed the digest.
These eight assertions passed locally. This demonstrates the chosen examples, not a claim that every serialization changes bytes or that HMAC collisions are impossible. The test does not implement a provider’s header parser or freshness policy. Node.js HMAC API
Diagram in text: receive a bounded original body with its signature headers; verify the configured provider format and freshness where supported; validate the parsed schema, account and state; then record accepted work durably and handle duplicates or crashes. Invalid signatures stop before processing.
The diagram shows the application’s verification boundary. It is not a claim that a receiver must preserve HTTP framing or TLS ciphertext. Use the body representation required by the provider and the adapter that delivers it to your handler.
Where did the original body disappear?
Trace one synthetic test request through the actual stack, in order:
- Ingress adapter: Does it expose bytes, text, or an envelope containing an encoded body? Follow that adapter’s documented decoding contract. Do not blindly decode twice.
- Middleware: Has a JSON or form parser already consumed or replaced the body? Keep a bounded original-body capture before transformations, or use the framework’s supported raw-body route.
- Verification call: Is it receiving that preserved representation, or a reconstructed string from the parsed object?
- Application handler: Does processing stop on missing, malformed, or invalid verification inputs?
The name rawBody is not proof of provenance. Test how it was populated. Likewise, moving one route does not repair a proxy or adapter that already transformed the signed input upstream.
Use synthetic fixtures first. A local byte comparison can isolate a transformation without exporting real webhook payloads. A body-length match is only a clue: two different messages can have the same length. Even a diagnostic body hash may reveal relationships between sensitive events, so limit access and retention rather than treating it as automatically anonymous.
Why a generic “HMAC check” is not enough
Provider formats differ. GitHub’s documented SHA-256 scheme uses X-Hub-Signature-256 with a sha256= prefix and a digest derived from the webhook secret and payload. Its validation guide recommends timing-safe comparison and shows verification before JSON processing. GitHub validation
Slack’s documented recipe signs a version, timestamp, and raw body together. It uses X-Slack-Signature and X-Slack-Request-Timestamp; the example rejects timestamps more than five minutes away from local time in either direction. Form-encoded requests need their original body too, not only JSON requests. Slack verification
Choose the verifier from trusted endpoint configuration. Do not try several weaker formats until one accepts a request. A provider library also needs the correct input: it cannot recover bytes that your application discarded.
If implementing a supported scheme yourself, validate its header format and encoding, then use an appropriate timing-safe comparison. Node’s timingSafeEqual requires equal-length inputs and does not make surrounding code automatically timing-safe. Prefer a maintained provider implementation over an improvised cross-provider parser. Node.js comparison contract
A valid signature still leaves work to do
Write three separate outcomes in your receiver tests:
- Authenticity and integrity: Did the configured verifier accept the signed input?
- Freshness and duplication: Did the scheme’s freshness check pass, and has this event already been accepted?
- Business validity: Does the event apply to the expected account and an allowed state transition?
A freshness window does not stop repeated processing inside that window. A body-only MAC does not create a timestamp policy. Do not silently add an unsigned timestamp and assume that it protects against replay.
After verification, retain the event’s schema and account checks and durable processing rules. Our webhook crash-window guide covers what survives a crash after acceptance; it solves a different problem from verifying the incoming message.
Before shipping, test the unchanged fixture, altered bytes, wrong configuration, absent or malformed headers, stale timestamps where the scheme supports them, and repeated valid delivery. Keep failures separate from successful business processing. If you need to share diagnostics, use the HAR redaction checklist rather than uploading a raw capture containing credentials or customer data.
Sources & scope
Official documentation review and a local synthetic HMAC byte-preservation test using Node.js. No provider webhook, framework integration, network request, or production receiver was tested.
- Stripe webhook signature troubleshooting
- GitHub webhook delivery validation
- Verifying requests from Slack
- Node.js crypto documentation
Publishing details & corrections
Prepared with AI assistance. Sources, any tests performed, and their limitations are described in the article.
No material corrections recorded for this version.