Plainwork

Plainwork / Webhook Spec Atlas / Webhook signature mismatch: 10 causes to check

Webhook signature does not match? Ten causes, in the order to check them

A signature mismatch almost never means the sender is broken. It means your code computed an HMAC over different bytes, with a different key, or compared it in a different encoding than the sender did. The checklist runs from the cheapest check to the most obscure, and the single-provider failure threads on the Shopify and Zoom developer forums mostly come down to the first four items. Every provider-specific detail below comes from the sender's own documentation, read on 13 October 2026; the dataset notes which page each fact came from.

  1. You verified a parsed body, not the raw one. Express express.json(), FastAPI models and Rails params all re-serialise JSON, which changes whitespace and key order. Stripe states it plainly: the raw body is required and any framework manipulation breaks verification. Capture the raw bytes first (in Express, express.raw({type: "application/json"}) on the webhook route only).
  2. Wrong encoding of the result. Shopify sends base64 in X-Shopify-Hmac-SHA256; GitHub, Stripe and Slack send hex. Comparing a hex digest to a base64 header always fails and the two look equally random.
  3. Missing or extra prefix. GitHub's header is sha256=<hex>, Slack's is v0=<hex>. Strip or add the prefix on one side only.
  4. You signed the body alone, but the sender signs more. Stripe signs timestamp.body, Slack signs v0:timestamp:body, Twitch signs message id + timestamp + body, and Square signs the notification URL + body. The signed string is the single most common thing to get wrong when copying a snippet from another provider.
  5. Wrong secret. Stripe's key is the whole whsec_... string for that endpoint; the Svix and Standard Webhooks format wants only the base64 part after whsec_. Shopify uses the app's client secret. Some senders issue a separate secret per destination, so a test secret will not verify a live delivery.
  6. A URL that is not byte-identical. For Square and Twilio the URL is part of the signed string, so scheme, port and query string must match what you registered in the console, including behind a proxy that rewrites http and https.
  7. Trailing newline or BOM. Reading the body through a text decoder, or pasting it into a test script, can add or drop a final newline. Hash the bytes you received.
  8. Timestamp tolerance. Many schemes cover a timestamp and reject old messages. A saved payload replayed in a test will fail once it is older than your tolerance; one provider's documented tolerance is as short as 5 seconds. Units differ too: Linear sends milliseconds, Stripe seconds.
  9. Several signatures in one header. During secret rotation Stripe and Svix-style headers can carry more than one signature. Accept the delivery if any one matches.
  10. Non-constant-time compare, or comparing different lengths. This does not cause a mismatch, but it is the bug that turns a fixed mismatch into a timing leak. Use crypto.timingSafeEqual after checking that lengths are equal.

Find which one it is in 30 seconds. The free webhook signature debugger recomputes the signature in your browser (nothing is uploaded) and tells you whether the cause is encoding, a re-serialised body, a wrong secret form or an expired timestamp.

Keeping the details straight across providers

If you maintain a receiver for more than one sender, the Webhook Spec Atlas puts header name, algorithm, signed string, encoding, replay window, timeout and retry schedule for 16 senders side by side, with fields the vendor does not state marked as such rather than guessed. See the dataset and tested Node verifiers ($9), or read the free timeouts and retries comparison.

More from the catalogue

Other small tools

See everything →