Stripe says No signatures found matching the expected signature for payload. GitHub says We could not verify your signature. Shopify says HMAC validation failed. Three different errors, but the root cause is usually one of seven things, and 90% of the time it is the first one.

This article ranks the seven causes by frequency, with a copy-paste fix and a one-line local debug command for each.

30-Second Overview

  • Webhook signature = HMAC-SHA256(secret, raw_body). The formula never changes; what changes is the header format and encoding details.
  • The 7 causes by frequency: parsed body instead of raw / wrong secret / wrong encoding (hex vs base64) / timestamp tolerance / secret committed to code / using === to compare / secret leaked to logs
  • Each provider uses different header format and encoding: GitHub uses sha256= plus hex, Shopify uses raw base64, Slack uses v0= plus hex, Stripe uses t=…,v1= plus hex
  • Use Piick Webhook Signature Validator in your browser to reproduce any signature locally and see whether it matches, all 5 providers supported

The 7 Causes, Most Common to Most Subtle

Cause 1: You verify the parsed JSON body, not the raw body

This is the root cause of 50% of webhook signature failures. Every provider signs raw bytes, but you are signing JSON.stringify(req.body). Express, Flask, and Next.js parse the body by default and then re-stringify it, key order or whitespace changes, and the signature no longer matches.

The fix:

  • Express: use express.raw() instead of express.json(), in the handler pass req.body (Buffer) directly to the verifier
  • Flask: use request.get_data(as_text=False), not request.json
  • Next.js API route: add export const config with api bodyParser false

Debug: print the first 30 characters of req.body and Content-Length, then compare against the original POST data.

Cause 2: Wrong secret

Local testing uses the Stripe CLI ephemeral secret (printed by stripe listen). In production you use the dashboard one. The same endpoint has different secret strings in two environments.

The fix: configure each endpoint in each environment independently. Read env vars from a secret manager. Never hard-code in source.

Debug: verify the same secret value in three places — dashboard, Stripe CLI, code env — and confirm they match exactly.

Cause 3: Encoding mismatch

90% of providers use hex, but Shopify alone uses base64. If you send hex to Shopify, the signature will never match.

The fix: follow the provider spec strictly, do not manually toggle hex / base64. Use Piick Webhook Signature Validator provider dropdown to pick the right one.

Debug: compare generated hex length (64 characters) against base64 length (44 characters). Length right means format is probably right.

Cause 4: Timestamp tolerance not set or set wrong (Stripe only)

Stripe sent the webhook 5 minutes ago, but your server still rejects it. Stripe includes a timestamp in t=…, and the SDK only accepts requests within ±5 minutes by default.

The fix: Stripe.webhooks.constructEvent(payload, sig, secret, tolerance=300). 300 seconds is enough for production, but do not set it too large (it is your replay-attack protection window).

Debug: print now - t difference. If it is above 300, your server clock is drifting. Sync NTP.

Cause 5: Secret committed to code

The secret leaks, and an attacker forges webhooks with the real secret.

The fix: rotate the secret immediately (env var from secret manager). Use git log -p plus grep -i whsec to find historical leaks.

Debug: treat the secret like a password — never let it into version control.

Cause 6: Comparing signatures with ===

Functionally works, but theoretically vulnerable to remote timing attacks. Attackers send repeated requests and measure response time differences to crack the signature byte-by-byte. Node’s crypto.timingSafeEqual and Python’s hmac.compare_digest are constant-time comparison, immune to this.

The fix:

  • Node: crypto.timingSafeEqual(Buffer.from(sig1), Buffer.from(sig2))
  • Python: hmac.compare_digest(sig1, sig2)

Debug: grep code for === and ==. Wherever signatures are compared, switch to the timing-safe version.

Cause 7: Debug-time secret leaked to logs

Works locally, fails for every webhook in production. Cause: you logged the secret during debugging (or committed it), then rotated. Webhooks now sign with the new secret, but the production env var was not updated.

The fix: log only the match status, not the secret or the signature itself. console.log(sig verify, matched: true).

Debug: git log -p grep for secret keywords. Confirm no historical commit leaked it.

How to Reproduce Any Signature Locally

  • Use Piick Webhook Signature Validator Compute tab. Fill in secret, raw body, and provider, the generated signature header goes straight into your webhook config.
  • Use Verify tab to paste the received header. See if it matches, instant feedback, no console.log and service restart.
  • This tool is fully local (browser Web Crypto API), no payload / secret upload, safe to use real secrets for debugging.

5-Provider Signature Format Cheat Sheet

ProviderHeaderEncodingSigned Payload
StripeStripe-Signature: t=[ts],v1=[hex]hex[ts].[raw body]
GitHubX-Hub-Signature-256: sha256=[hex]hexraw body
ShopifyX-Shopify-Hmac-SHA256: [base64]base64raw body
SlackX-Slack-Signature: v0=[hex]hexv0:[ts]:[raw body]
Genericcustomhexraw body
  • Shopify is the only one using base64, others all use hex
  • Only Stripe and Slack include a timestamp
  • Only GitHub (sha256=) and Slack (v0=) have a prefix
  • See the full reference by picking a provider in webhook-signature-validator. Each comes with a built-in example payload.
  • Use express.raw() / request.get_data() to handle webhooks, never JSON.stringify(parsedBody)
  • Follow provider encoding strictly. Stripe / GitHub / Slack = hex, Shopify = base64
  • Compare signatures with timingSafeEqual / compare_digest, not ===
  • Set 5-minute timestamp tolerance for Stripe to defend against replay attacks
  • Secrets always go through env vars, never in code, never in logs

Want to verify a signature yourself? Use Piick Webhook Signature Validator in your browser to compute and verify signatures, all 5 providers covered, data stays in the browser. Pair it with JWT Decoder for complete coverage of your auth and webhook security tool stack.