Stripe webhooks
Stripe returns Webhook signature verification failed and the dashboard tells you nothing else. These failures are not random — each cause leaves a distinct fingerprint. Match your symptom and jump to the fix.
Create a catchbin endpoint with the Stripe provider, then add its URL in Stripe under Developers → Webhooks → Add endpoint. Copy the endpoint’s signing secret (it starts with whsec_) and paste it into the catchbin endpoint so catchbin can verify deliveries. catchbin stores the secret encrypted and never shows it again.
How Stripe signs
Section titled “How Stripe signs”Every delivery carries a Stripe-Signature header:
Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt is the Unix timestamp when Stripe signed the event. v1 is a hex HMAC-SHA256 digest. The signed message is not the JSON object — it is the timestamp, a period, then the raw request body exactly as sent:
signed_payload = "{t}.{raw_body}"expected = HMAC_SHA256(signing_secret, signed_payload)The comparison is on bytes. Two payloads that parse to the same object can still produce different digests — which is why most failures are byte-level.
Quick answer
Section titled “Quick answer”| Symptom | Cause | Fix |
|---|---|---|
| Never worked once, secret is definitely right | Body mutated by middleware | Verify the raw bytes before parsing |
Secret you pasted starts with sk_ |
Wrong secret | Use the whsec_ signing secret |
| Worked, then every event failed at once | Secret rotated | Copy the current signing secret |
| Intermittent, or fails near a time boundary | Clock skew | Fix server time (NTP) |
| A parse/null error, not “didn’t match” | Header didn’t parse | Check the header format |
Body mutated by middleware
Section titled “Body mutated by middleware”The one you’ll hit most.
Symptom. Total failure from the very first event on a brand-new endpoint where you are certain the secret is right. If it has never worked once, suspect the body before the secret.
Why. Stripe computed the signature over the bytes it sent; your handler is verifying different bytes that happen to parse the same. Something rewrote them — a body-parser that consumes the raw stream (express.json(), Spring’s @RequestBody), a re-serialization before verification, a pretty-printing logger, a charset round-trip, or an added trailing newline.
Confirm it. Compare the byte length of the body you are about to hash against the Content-Length Stripe sent. Any difference means something touched the bytes first. This is the check catchbin runs first, and it surfaces the byte delta directly.
Fix. Read and verify the raw bytes at the edge, before anything deserializes them.
// Express: raw parser on the webhook route ONLY, before any JSON parser.app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { const event = stripe.webhooks.constructEvent( req.body, // a Buffer, not a parsed object req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET, ); res.json({ received: true });});Wrong secret: API key, not signing secret
Section titled “Wrong secret: API key, not signing secret”Symptom. Total failure from the first event, same as body mutation. The tell is the secret you pasted: it starts with sk_live_ or sk_test_.
Why. That is your Stripe API key. The webhook signing secret is a different value that starts with whsec_. They sit one tab apart in the dashboard and get mixed up constantly.
Fix. Open the webhook endpoint in Stripe and copy its signing secret (whsec_…). Do not use anything from the API keys page.
Secret rotated on Stripe’s side
Section titled “Secret rotated on Stripe’s side”Symptom. A clean break — everything verified, then at one moment every event started failing. That sharp before/after edge separates this from body mutation, where nothing ever worked.
Why. The endpoint’s signing secret was rotated and your verifier still holds the old one.
Fix. Copy the current signing secret. During a planned rotation Stripe signs with both the old and new secret for a grace window, so the header carries more than one v1 signature — accept the request if any of them matches, not just the first.
Timestamp outside the tolerance window
Section titled “Timestamp outside the tolerance window”Symptom. Failures that come and go, or cluster near the edge of the window. Stripe’s SDKs reject an event once its signed timestamp is more than five minutes (300 seconds) old.
Why. Usually the server clock has drifted — a roughly constant offset on every event. If instead the gap is hours, you are probably looking at a replayed request, not skew.
Fix. Check NTP first (timedatectl, chronyd). The timestamp is signed for replay protection, so widening the tolerance to hide a clock problem weakens that protection — treat a wider tolerance as a diagnostic, then put it back.
Header didn’t parse
Section titled “Header didn’t parse”Symptom. The failure happens before any comparison — a null or parse error, not “signatures don’t match”.
Why. Uncommon, but a major API-version change can alter the header shape and break a hand-rolled parser before it computes a digest.
Fix. Log the raw Stripe-Signature value and compare it against the expected t=…,v1=… form.
Replay a Stripe event
Section titled “Replay a Stripe event”After you fix the handler, replay the captured event. To a local target, strip the signature. To a target that verifies, use --resign with the target’s whsec_ secret so catchbin signs fresh:
catchbin replay <event-id> --target http://localhost:3000 --strip