Verifying a webhook signature looks like three lines of code — hash the body, compare it to a header, done. That's exactly why it's one of the most commonly botched pieces of integration code an agent writes: the check "passes" in a quick manual test and then fails, or silently accepts a forged request, the moment it hits a real delivery pipeline. This page collects the documented failure modes across six providers so an agent (or the human reviewing its output) can check its own implementation against real, sourced gotchas instead of rediscovering them in production.
The single most common cause of "valid webhook, failed verification" is raw-body handling. Providers sign the exact bytes they sent over the wire; most web frameworks parse those bytes into a JSON object before your handler ever runs, and once that happens the original byte sequence is gone. Re-serializing the parsed object — different key order, spacing, number formatting — produces a different digest than the one the provider computed, so a body-parser mounted ahead of the webhook route (Express's express.json(), or any framework's default JSON middleware) breaks signature checks for every request, legitimate or not. The fix is always the same shape: capture the raw bytes explicitly, before any parsing middleware touches them, and hash that.
Getting the raw bytes right isn't the whole story. Three of the six providers below have official documentation examples that get the comparison step wrong: plain ===/string equality instead of a constant-time function, which leaks timing information an attacker can use to forge a valid signature one byte at a time — or a type mismatch that crashes the check outright. And even a mathematically correct HMAC check is incomplete without a timestamp-tolerance window: without one, a captured request can be replayed indefinitely (or, symmetrically, a naive check with no tolerance at all starts rejecting legitimate events the moment your server clock drifts from the provider's).
The gotcha: a JSON body-parser mounted ahead of the webhook route mutates the raw bytes Stripe actually signed — signature checks fail for legitimate events; each endpoint also has its own signing secret (test vs. live), and secret rotation means a request can carry more than one v1= signature to check.
The gotcha: Plaid doesn't use HMAC at all — it signs a JWT (alg must be ES256), and the verification key has to be fetched and cached by kid since Plaid rotates it; the body hash is checked against the JWT's own request_body_sha256 claim, not a header comparison.
The gotcha: the event query parameter DocuSign appends to your returnUrl redirect is advisory only and can be spoofed by the client — the real trust check is a server-side GET on the envelope-status endpoint, not the token sitting in the URL.
The gotcha: Typeform's own official Node.js example compares signatures with plain === instead of a constant-time function — copy-pasted verbatim, it's a timing side-channel baked into their own docs (their Ruby example, by contrast, uses a constant-time compare).
The gotcha: Attio's own official Node.js example hands plain strings straight to crypto.timingSafeEqual(), which only accepts Buffers — copy-pasted verbatim, it throws ERR_INVALID_ARG_TYPE on the very first real webhook, valid or not.
The gotcha: the HMAC key is a dedicated per-webhook signing key from the dashboard's Webhooks tab — compute with your OAuth client secret and every check fails; delivery/courier events also carry a legacy x-postmates-signature alias, but refund events sign only x-uber-signature.
Pulled directly from each guide's documented pattern — no field filled in that isn't traceable to a verified route or the provider's own docs.
| Provider | Signature header | Algorithm | Encoding | What actually breaks it |
|---|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 | v1=<hex>, timestamped | raw body mutated by a JSON body-parser before the handler runs |
| Plaid | Plaid-Verification | JWT, ES256 | base64url JWT | trusting an un-rotated cached key instead of re-fetching by kid |
| DocuSign | — (this guide covers the signing ceremony, not an HMAC webhook header) | — | — | trusting the client-supplied returnUrl event param instead of the envelope-status API |
| Typeform | Typeform-Signature | HMAC-SHA256 | sha256=<base64> | official Node.js example uses non-constant-time === comparison |
| Attio | Attio-Signature (dup. X-Attio-Signature) | HMAC-SHA256 | hex | official Node.js example passes strings into a Buffer-only timingSafeEqual call |
| Uber Direct | x-uber-signature (legacy dup. x-postmates-signature; refunds sign x-uber-signature only) | HMAC-SHA256 | hex | hashing with the OAuth client secret instead of the per-webhook signing key; unicode escapes mutated by re-serialization |
Agents wired to Waymark's MCP server pull the documented pattern — including provider-specific gotchas like the two official-example bugs above — at plan time instead of copy-pasting an official snippet verbatim and shipping a timing side-channel or a crash. Reads are free and keyless.
# Any MCP client — one call, no key:
waymark_query({ "task": "verify webhook signature" })
# Or plain HTTP:
curl -s "https://mcp.waymark.network/search?q=verify%20webhook%20signature"