Test Stripe Webhooks Locally
Your endpoint keeps returning 400 "No signatures found matching the expected signature" — usually a parsed body or the wrong whsec_. Get a stable URL Stripe can reach, see the exact Stripe-Signature it sent, and replay the delivery until it verifies.
30-second setup
From a stuck handler to a live Stripe delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 4242. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL — something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3In the Stripe Dashboard go to Developers → Webhooks → Add endpoint and paste your LocalCan URL with your path, e.g.
https://your-app-12.localcan.dev/webhook. Select the events to send, then copy that endpoint’s signing secret (whsec_…). - 4Fire one:
stripe trigger payment_intent.succeeded, or open any event in the Dashboard and click Resend. Heads-up:stripe listenprints its ownwhsec_— different from this endpoint’s secret. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body — expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Stripe webhooks
Stripe sends webhooks from its servers to a public HTTPS URL. localhost:4242 only exists on your machine, so Stripe has nothing to connect to and every delivery shows up in the Dashboard as a failed attempt.
You need a public URL that forwards to your local handler — and one that does not change on restart, or you will be re-pasting it into the Dashboard and re-copying the signing secret every morning.
What makes the Stripe loop fast
Set the endpoint once
Your …localcan.dev URL survives restarts and sleeps, so the endpoint you register in the Stripe Dashboard keeps working tomorrow. The URL never changes, so its whsec_ signing secret stays valid — no re-pasting a fresh URL and re-selecting events every day.
See the raw Stripe-Signature
The inspector shows the exact Stripe-Signature header and the unparsed body Stripe sent. That is precisely what you need to debug constructEvent failures: confirm the t= timestamp and v1= digest against what your code computes, instead of guessing why the hash did not match.
Replay while you fix the handler
Hit Replay to send the identical captured event again — same bytes, no need to stripe trigger a new one or burn test data. Iterate on idempotency and database writes against the real payload until the handler is right.
Inspect & replay Stripe events
Common events
payment_intent.succeededcheckout.session.completedinvoice.paidcustomer.subscription.updatedcharge.refunded
These are the events most apps wire up first. Trigger one with stripe trigger, watch it land in Inspect Traffic with its full headers and JSON body, and expand it to read data.object. When the handler throws, fix the code and hit Replay — LocalCan re-sends the exact same event, so you are testing against the real payload, not a hand-rolled fixture.
One Stripe-specific caveat: the signature check rejects a t= timestamp older than five minutes by default, so replay while you iterate. A delivery you captured an hour ago will fail the timestamp check if you forward it untouched.
Verify the Stripe signature
- Header
Stripe-Signature- Algorithm
- HMAC-SHA256, hex-encoded
- What's signed
- Stripe signs the string
{t}.{raw body}— thet=value from the header, a literal dot, then the exact bytes of the request body. The result is thev1=hex digest. Parse the JSON before reading the raw body and the signature can never match. - Secret
- The endpoint signing secret (
whsec_…) from the Dashboard endpoint.stripe listenprints a differentwhsec_…for events it forwards — use the one that matches how the event is being sent.
import express from 'express'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET // whsec_...
const app = express()
// Stripe needs the RAW body — mount express.raw BEFORE any JSON parser.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature']
try {
// constructEvent recomputes HMAC-SHA256 over "{t}.{body}" and checks the
// 5-minute tolerance. Throws on a bad signature, wrong secret, or old timestamp.
const event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret)
if (event.type === 'payment_intent.succeeded') {
// handle it
}
res.json({ received: true })
} catch (err) {
res.status(400).send('Webhook Error: ' + err.message)
}
})LocalCan does not verify the signature for you — constructEvent does — but it hands you both halves so a failure is obvious instead of silent. The inspector shows the Stripe-Signature Stripe actually sent and the untouched body, so you can tell a wrong secret from a parsed body from a stale timestamp in seconds, then replay the same request to confirm the fix.
LocalCan vs Stripe CLI
Stripe ships an excellent local tool. The honest split: use the Stripe CLI when your whole loop is Stripe; reach for LocalCan when one stable URL has to serve Stripe and everything else, or when you want a GUI inspector and replay the whole team can use.
| LocalCan | Stripe CLI | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | Forwards to localhost; no public URL |
| Works across providers | One URL for Stripe, GitHub, Shopify… | Stripe only |
| Forward without a URL | Public URL (also reachable by others) | stripe listen --forward-to — no URL needed |
| Signing secret | Use your Dashboard whsec_ | Prints its own whsec_ for the listener |
| Inspect full payloads | GUI inspector, headers + body, on by default | Terminal logs / --print-json |
| Replay a delivery | One-click Replay of the exact request | stripe events resend <id> |
| Trigger test events | Use stripe trigger / Dashboard | stripe trigger <event> built in |
| Share with a teammate | Shareable URL + basic auth | Local only |
Stripe webhook FAQ
Stop guessing why the webhook 401'd. Get a stable Stripe endpoint, read every delivery, and replay it until your handler is right.
Free trial. No credit card required.