Skip to content

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.

Every delivery carries a Stripe-Signature header:

Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t 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.

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

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 });
});

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.

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.

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.

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.

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:

Terminal window
catchbin replay <event-id> --target http://localhost:3000 --strip