Free debug tool · runs in your browser

Webhook Signature Playground

Compute and verify HMAC-SHA256 webhook signatures for Stripe, GitHub, Shopify, Slack, and generic providers. Built for the 2am moment when your verifier returns false and you need to know why.

Your secret never leaves your browser.

Your secret never leaves your browser. All HMAC computation runs locally via the Web Crypto API. No data is sent to our servers, no analytics on your inputs, no localStorage.

Stripe signs the string `<unix-timestamp>.<raw-body>` with HMAC-SHA256, hex-encoded. The timestamp is the first integer in the Stripe-Signature header (t=...).

The exact bytes the provider sends. Sign on raw body — never JSON.parse() then JSON.stringify().

Stripe gives this as `whsec_…`. GitHub & Slack use whatever string you set in the dashboard.

Why your signature is failing — the 5 usual suspects

If you landed here from a Google search like “stripe webhook signature verification failed”, one of these is almost certainly the cause. Read in order — each pitfall builds on the last.

1You parsed the body before verifying the signature

Stripe and most other providers sign the exact bytes they sent — including whitespace, key order, and trailing newlines. The moment Express or Flask runs JSON.parse() on the body and your code re-serializes it, the signature no longer matches the bytes you signed. Use express.raw({ type: "application/json" }) on the webhook route, before any json parser. In Flask, read request.get_data(as_text=False), not request.json.

// Express.js — RIGHT
app.post('/webhook',
  express.raw({ type: 'application/json' }), // ← raw body, not json
  (req, res) => {
    const sig = req.headers['stripe-signature']
    const event = stripe.webhooks.constructEvent(
      req.body,           // Buffer of raw bytes
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    )
    res.json({ received: true })
  }
)

2You're using the wrong endpoint secret

Stripe issues a different signing secret per webhook endpoint. Test-mode and Live-mode are separate secrets — using the test-mode secret in production fails silently with a signature mismatch. Same trap with the Stripe CLI: `stripe listen` generates an ephemeral signing secret that is different from your dashboard endpoint's secret. Confirm which secret your code is loading by logging the first 6 characters (whsec_xxxxxx) — never log the full value.

# Sanity check before going live (Python)
import os
secret = os.environ['STRIPE_WEBHOOK_SECRET']
assert secret.startswith('whsec_'), f'Expected whsec_, got {secret[:6]}'
print(f'Loaded secret: {secret[:10]}…')

3The timestamp is outside the tolerance window

Stripe rejects signatures older than 5 minutes by default. If you're testing with a stored payload from earlier today, the HMAC itself may be correct but the timestamp check fails first. Slack has the same 5-minute rule. The fix in production is not to widen the tolerance — it's to defend against replay attacks at the receiver layer. The fix while testing is to generate a fresh signature against the current timestamp using this playground's Compute mode.

# Stripe SDK respects tolerance automatically:
event = stripe.Webhook.construct_event(
    payload, sig_header, webhook_secret,
    tolerance=300,  # default 5 min — don't make this larger in prod
)

4Hex vs base64: Shopify is the odd one out

Most providers HMAC-then-hex-encode. Shopify HMAC-then-base64-encodes. Comparing your computed hex against the X-Shopify-Hmac-Sha256 header will always fail because they're different encodings of the same bytes. The fix is one character in your hashing call: digest('base64') instead of digest('hex').

// Shopify verification — note .digest('base64')
const hmac = crypto
  .createHmac('sha256', SHOPIFY_API_SECRET)
  .update(rawBody)         // raw, not parsed
  .digest('base64')        // base64, NOT hex

const ok = crypto.timingSafeEqual(
  Buffer.from(hmac, 'utf8'),
  Buffer.from(req.headers['x-shopify-hmac-sha256'], 'utf8')
)

5Header parsing differs between frameworks

HTTP headers are case-insensitive but some frameworks normalize them differently. Express returns headers lowercased; raw Node http.IncomingMessage preserves case. If your code reads req.headers.StripeSignature directly instead of req.headers['stripe-signature'] (lowercase), you get undefined and your verifier explodes. Multi-value headers are another gotcha — Stripe-Signature can contain comma-separated v1 entries when a secret has been rotated.

// Always read headers lowercase, defensively
const sig =
  req.headers['stripe-signature'] ||
  req.headers['Stripe-Signature']    // some proxies preserve case
if (Array.isArray(sig)) sig = sig[0] // multi-value defensive

Related

Frequently Asked Questions

Does my webhook secret actually stay in my browser?
Yes. All HMAC-SHA256 computation runs locally in your browser via the Web Crypto API (crypto.subtle.sign). We do not send your secret to our servers. We do not localStorage it. We do not log it. Open your browser dev tools, switch to the Network tab, and click "Compute Signature" — you will see zero outgoing requests. The page is also fully readable without JavaScript so you can audit it.
Why is my computed signature different from what my server received?
Five common reasons, all covered in detail above: (1) the body was JSON-parsed and re-serialized before verification, changing the bytes; (2) you are using the wrong endpoint secret (test-mode vs live-mode); (3) the timestamp is outside the tolerance window; (4) hex vs base64 encoding mismatch (Shopify uses base64); (5) header case-sensitivity in your framework. Use Verify mode to paste both your computed signature and the one your server received — the playground will tell you which of these is happening.
Which providers are supported?
Stripe, GitHub, Shopify, Slack, and a generic HMAC-SHA256 mode for any provider that signs raw body bytes. Twilio uses a different scheme (it signs URL + sorted form parameters, not the raw body) and is not supported in this version. For Twilio, see Twilio's official validation docs.
Can I use this for production verification?
No. This is a debugging tool, not a production library. For production, use the official SDK from your provider — Stripe's constructEvent(), GitHub's standard HMAC verification, Shopify's built-in verifier. Those handle edge cases (rotated secrets with multiple v1 entries, timing-safe comparison guarantees, replay-attack defense) more robustly than a browser tool can. Use this playground when an SDK call fails and you need to know why.
Why is "Verify mode" verbose with hints instead of a simple yes/no?
Because a one-line 'doesn't match' answer sends you back to Google. The whole point of the playground is to solve your bug on this page. When verification fails, the verdict tells you the most likely cause based on the inputs — wrong encoding, expired timestamp, header didn't parse, common middleware footguns — so you can fix it without context-switching.
How is this different from /docs/webhook-signing?
/docs/webhook-signing is the reference for verifying WebhookWhisper's outbound signatures (the X-WebhookWhisper-Signature header, Stripe-compatible format). This playground is provider-agnostic — paste any provider's payload and signature and debug it. The two complement each other: docs for OUR signatures, playground for everyone else's.

Want to receive these signatures live?

Get a free webhook URL that captures the exact bytes Stripe / GitHub / Shopify send — including the signature header — so you can debug without staring at logs.

Get a free webhook URL