NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Stripe delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 4242. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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_…).
  4. 4Fire one: stripe trigger payment_intent.succeeded, or open any event in the Dashboard and click Resend. Heads-up: stripe listen prints its own whsec_ — different from this endpoint’s secret.
  5. 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.succeeded
  • checkout.session.completed
  • invoice.paid
  • customer.subscription.updated
  • charge.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} — the t= value from the header, a literal dot, then the exact bytes of the request body. The result is the v1= 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 listen prints a different whsec_… for events it forwards — use the one that matches how the event is being sent.
Node.js
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.

LocalCanStripe CLI
Stable public URLPersistent …localcan.dev, survives restartsForwards to localhost; no public URL
Works across providersOne URL for Stripe, GitHub, Shopify…Stripe only
Forward without a URLPublic URL (also reachable by others)stripe listen --forward-to — no URL needed
Signing secretUse your Dashboard whsec_Prints its own whsec_ for the listener
Inspect full payloadsGUI inspector, headers + body, on by defaultTerminal logs / --print-json
Replay a deliveryOne-click Replay of the exact requeststripe events resend <id>
Trigger test eventsUse stripe trigger / Dashboardstripe trigger <event> built in
Share with a teammateShareable URL + basic authLocal 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.