Stripe webhooks: the complete testing guide
To test Stripe webhooks, point a test-mode endpoint at a URL you can watch (a free webhooks.sh bin, no signup) or run stripe listen --forward-to localhost, then verify Stripe-Signature: HMAC-SHA256 over the timestamp and raw body with your whsec_ secret, 5-minute tolerance.
Stripe is usually the first webhook integration a codebase gets, and the first one to silently break in production. This guide covers the whole loop: pointing Stripe at a URL you can watch, reading the events that arrive, and verifying signatures the way Stripe expects — no skipped steps, no "it worked on my machine".
Get a URL you can watch
You need a public HTTPS endpoint before Stripe will send anything. Two options:
- A throwaway inspection bin. Open view.webhooks.page, create a bin (no account), and copy the ingest URL. Every request Stripe sends lands there live — method, headers, full body, timing.
- The Stripe CLI.
stripe listen --forward-to localhost:3000/webhooks/stripeforwards events to your local app. Great for driving your own handler; less great for seeing the raw request exactly as Stripe sent it.
The two complement each other: the bin shows ground truth on the wire, the CLI drives your code. When behavior differs between them, trust the wire.
Point Stripe at it
In the Stripe Dashboard: Developers → Webhooks → Add endpoint, paste your bin URL, and pick the events you care about (payment_intent.succeeded, checkout.session.completed, customer.subscription.* are the usual suspects). Stripe shows the endpoint's signing secret — a whsec_… string — after creation. Save it; you need it for verification.
Then trigger traffic. Either perform an action in test mode (create a checkout session, attach a card) or use the CLI:
stripe trigger payment_intent.succeeded
The event appears in your bin within a second or two, with every header intact.
What Stripe sends
Every delivery is a POST with Content-Type: application/json and a body shaped like:
{
"id": "evt_1…",
"object": "event",
"type": "payment_intent.succeeded",
"data": { "object": { … } },
"livemode": false,
"created": 1756…
}
The header that matters is Stripe-Signature:
Stripe-Signature: t=1696167045,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t is the timestamp of the attempt, v1 the HMAC-SHA256 signature. On retries, multiple v1 values can appear — one per endpoint secret, if you have several.
Verify the signature (the part everyone gets wrong)
The signed payload is not the request body. It is the timestamp, a dot, then the raw, unparsed body:
signed_payload = "${t}." + rawBody
expected = HMAC_SHA256(signing_secret, signed_payload) // hex
Compare expected against every v1 value in the header with a constant-time comparison, and reject the request if t is more than a few minutes old (Stripe's default tolerance is 5 minutes) — that window is your replay-attack protection.
Node/Express, done right:
import crypto from "node:crypto";
import express from "express";
const app = express();
// Raw body, not express.json() — parsing destroys the bytes you must verify.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Stripe-Signature") ?? "";
const pairs = header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
});
const t = pairs.find(([k]) => k === "t")?.[1] ?? "";
const v1s = pairs.filter(([k]) => k === "v1").map(([, v]) => v); // rotation/retries can send several
const signed = t + "." + req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET!)
.update(signed)
.digest();
const valid = v1s.some((v) => {
const got = Buffer.from(v, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!valid || age > 300) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// …handle event.type…
res.sendStatus(200);
});
Don't want to hand-roll it? Stripe's official SDKs (stripe.webhooks.constructEvent in Node) do exactly this, including the timestamp check.
To sanity-check your understanding before wiring it into code, paste a captured request into our HMAC signature verifier — it runs entirely in your browser.
Failures, retries, and duplicates
- Respond fast. Anything but a 2xx — or no response within ~20 seconds — counts as a failure. Do slow work (emails, provisioning) asynchronously after acknowledging.
- Retries are real. Stripe retries failed deliveries for up to 3 days with exponential backoff. Your handler must be idempotent: store the
id(evt_…) of processed events and skip duplicates. - Order is not guaranteed.
customer.subscription.updatedcan arrive beforecustomer.subscription.created. Fetch current state from the API instead of assuming event order.
Common pitfalls
- Verifying the parsed body. JSON re-serialization changes key order and whitespace. Verification fails. Always verify the raw bytes.
- Body-parser middleware ordering. A global
express.json()mounted before your webhook route eats the raw body. Mountexpress.raw()on the webhook route first. - Test vs live secrets. Each endpoint has separate test-mode and live-mode signing secrets. A test event will never verify against the live secret.
- CLI secret vs endpoint secret.
stripe listenprints its ownwhsec_…for the forwarded session — different from the Dashboard endpoint's secret. Use the right one for the source you're testing.
The fast feedback loop
- Create a bin at view.webhooks.page and paste it into a Stripe test-mode endpoint.
stripe triggera few event types; watch them arrive with headers intact.- Copy a real
Stripe-Signature+ body into the verifier and confirm your mental model. - Move the URL into your app (or keep the bin and upgrade to a persistent endpoint when you're tired of re-pasting).
Once it works against the wire, it works everywhere — Stripe's delivery is the boringly reliable part. It's your handler that needs the testing.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.