To test Stripe webhooks locally, run stripe listen --all-snapshot --forward-to localhost:3000/api/webhooks/stripe, put the whsec_ secret it prints in your app's environment, and fire an event from a second terminal with stripe trigger checkout.session.completed. That covers the loop where Stripe is the only service calling your laptop. When you need a registered endpoint, with its delivery log, retries and Resend button, point it at a Public URL (also called a tunnel) that forwards to the same handler.
This guide covers both, plus signed test fixtures for offline work. Every terminal output below comes from one session against a Stripe sandbox (test mode) on October 19, 2026, with Stripe CLI 1.54.0, stripe-node 23.0.0 and LocalCan 3.3.2 (CLI 1.3.2). An AI assistant helped draft this post. The commands and numbers come from my own runs.
Why Local Webhook Testing Matters
Stripe tells your app about payments, subscription changes and disputes by sending each event as an HTTPS POST to a webhook endpoint. Your server receives it, verifies the signature and processes the event.
Stripe only delivers to an endpoint at a publicly accessible HTTPS URL↗, and Stripe's servers can't reach localhost:3000. There are three common ways around that:
- Deploy to staging. Slow iteration, a shared environment, noisy logs.
- Use the Stripe CLI. It keeps a connection open to Stripe and forwards events to localhost, with no public address involved.
- Use a tunnel. A Public URL forwards to your local server, so a registered endpoint can point at it.
Approach 1: Stripe CLI (Quick Testing)
The Stripe CLI is Stripe's official tool for local development. It forwards webhook events to your local server.
Setup
Install the CLI and log in:
# macOS
brew install stripe
# macOS, Linux or Windows, with Node.js
npm install -g @stripe/cli
# Log in to your Stripe account
stripe login
In CI or a script, skip stripe login and pass a sandbox key with --api-key sk_test_... on each command.
Start forwarding events to your local endpoint:
stripe listen --all-snapshot --forward-to localhost:3000/api/webhooks/stripe
You'll see:
Ready! You are using Stripe API Version [2026-04-22.dahlia]. Your webhook signing secret is whsec_xxxxxxxx (^C to quit)
Put that value in STRIPE_WEBHOOK_SECRET. It belongs to the CLI, not to any endpoint in your Dashboard, and it stays the same between runs. Two stripe listen sessions in a row printed the identical secret, as Stripe's CLI reference↗ says they should.
Stripe CLI 1.51.0 (September 2026) added --all-snapshot when it redesigned the event flags↗, and since then --forward-to needs an event selection: --all-snapshot for every event, or --events checkout.session.completed,invoice.paid for a list. The bare form, which Stripe's webhooks guide↗ still shows as of October 2026, now stops with:
must specify events to forward using --events, --all-snapshot, or --all-thin
Triggering Test Events
In another terminal, trigger an event:
stripe trigger checkout.session.completed
Checking for new versions...
Setting up fixture for: product
Running fixture for: product
Setting up fixture for: price
Running fixture for: price
Setting up fixture for: checkout_session
Running fixture for: checkout_session
Setting up fixture for: payment_page
Running fixture for: payment_page
Setting up fixture for: payment_method
Running fixture for: payment_method
Setting up fixture for: payment_page_confirm
Running fixture for: payment_page_confirm
Trigger succeeded! Check dashboard for event details.
The trigger runs fixtures that create a product, a price, a Checkout Session and a payment in your sandbox, so stripe listen forwarded seven events, not one:
2026-10-19 12:01:18 --> product.created [evt_1USDNa7TSJOOeW4pjVn2TOZ7]
2026-10-19 12:01:18 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_1USDNa7TSJOOeW4pjVn2TOZ7]
2026-10-19 12:01:19 --> price.created [evt_1USDNa7TSJOOeW4pLTjdubBX]
2026-10-19 12:01:19 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_1USDNa7TSJOOeW4pLTjdubBX]
2026-10-19 12:01:22 --> charge.succeeded [evt_3USDNc7TSJOOeW4p09qYD45z]
2026-10-19 12:01:22 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_3USDNc7TSJOOeW4p09qYD45z]
2026-10-19 12:01:22 --> payment_intent.succeeded [evt_3USDNc7TSJOOeW4p0Lyzcfzo]
2026-10-19 12:01:22 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_3USDNc7TSJOOeW4p0Lyzcfzo]
2026-10-19 12:01:22 --> payment_intent.created [evt_3USDNc7TSJOOeW4p0nQPuStQ]
2026-10-19 12:01:22 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_3USDNc7TSJOOeW4p0nQPuStQ]
2026-10-19 12:01:22 --> checkout.session.completed [evt_1USDNe7TSJOOeW4pDMM15Hqj]
2026-10-19 12:01:22 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_1USDNe7TSJOOeW4pDMM15Hqj]
2026-10-19 12:01:24 --> charge.updated [evt_3USDNc7TSJOOeW4p0s86g9h7]
2026-10-19 12:01:24 <-- [200] POST http://localhost:3000/api/webhooks/stripe [evt_3USDNc7TSJOOeW4p0s86g9h7]
Your handler receives every event the fixture creates, so return 200 for the types you don't handle. In this run, payment_intent.succeeded arrived before payment_intent.created. Stripe doesn't guarantee event order↗, even within one second, so write handlers that check the current state and fetch a missing object from the API instead of assuming a sequence.
Two more triggers worth knowing:
# A new subscription
stripe trigger customer.subscription.created
# A failed invoice payment
stripe trigger invoice.payment_failed
Run stripe trigger --help for the full list of supported events.
A Minimal Webhook Handler
Here's a basic Node.js/Express handler to get started:
import Stripe from 'stripe';
import express from 'express';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const router = express.Router();
// Stripe needs the raw body for signature verification
router.post('/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET // whsec_... from CLI or dashboard
);
} catch (err) {
console.error(`Signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
console.log(`Payment succeeded for session: ${session.id}`);
// Fulfill the order, activate license, send email, etc.
break;
case 'customer.subscription.updated':
const subscription = event.data.object;
console.log(`Subscription ${subscription.id} updated to ${subscription.status}`);
break;
case 'invoice.payment_failed':
const invoice = event.data.object;
console.log(`Payment failed for invoice: ${invoice.id}`);
// Notify customer, retry logic, etc.
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
}
);
export default router;
When to Use the Stripe CLI
For Stripe-only work the CLI is the fastest loop. The listener forwards every event in your sandbox, so a test-card purchase on your own checkout page reaches your handler with your own prices and metadata, the same as a triggered event.
What it skips is the registered endpoint:
- No endpoint. The Event deliveries tab, automatic retries and the Dashboard's Resend button all belong to a registered endpoint, and CLI forwarding has none.
- No URL for anyone else. A teammate, a hosted preview or another webhook provider has nothing to call.
- A second secret. The CLI signs what it forwards with its own
whsec_. Move to a registered endpoint and the secret changes with it (see Common Pitfalls).
For those, register an endpoint on a Public URL.
Approach 2: A LocalCan Public URL (End-to-End Testing)
A Public URL forwards requests from a public HTTPS address to your local server. Register it in Stripe and events reach your machine the way they will reach production: through an endpoint with its own secret, delivery log and retries.
Setting Up a Tunnel with LocalCan
From the terminal:
localcan http 3000
It prints a .local name and a Public URL such as https://clever-eagle.localcan.dev. This quick tunnel closes when you press Ctrl+C, and the next run gets a new URL, so it suits a one-off test.
For an endpoint you register once, add your app in the desktop app with the target http://localhost:3000, then click Add Public URL on its card and choose New Public URL. On a paid plan that URL stays reserved for the app across restarts, so the Stripe endpoint goes on pointing at your machine.
On the Free plan a Public URL lives on trylocalcan.com, lasts up to 60 minutes and the next session gets a new one, so you register the endpoint again each time. The Public URLs overview covers both.
Configuring Stripe to Use Your Tunnel
In Stripe's Workbench, open the Webhooks tab↗ and click Create an event destination:
- Choose Your account.
- Choose the Snapshot payload and an API version. Stripe recommends Thin for new integrations, but the handlers in this post use
constructEventand readevent.data.object, which is the snapshot format. - Select the events your handler uses, such as
checkout.session.completed. - Choose Webhook endpoint and enter
https://clever-eagle.localcan.dev/api/webhooks/stripe. - On the endpoint's page, click Reveal secret.
Put that secret in your environment and restart your handler:
STRIPE_WEBHOOK_SECRET=whsec_your_endpoint_secret_here
The Full Flow
Now you can test the complete pipeline:
- Open your app's checkout page in the browser.
- Pay with a Stripe test card↗,
4242 4242 4242 4242. - Stripe sends
checkout.session.completedto your Public URL. - LocalCan forwards it to
localhost:3000, where your handler verifies it with the endpoint secret. - You see the logs in your terminal and can stop on a breakpoint.
In the run, stripe trigger stood in for the checkout page. The delivery came through the Public URL, and the test handler, which logs every event that passes constructEvent, printed:
verified checkout.session.completed evt_1USDOO7TSJOOeW4ppQci0XeY
That line also shows the body arrived unchanged, since one changed byte breaks the signature.
Inspecting Traffic
While the desktop app runs, LocalCan's traffic inspector records the requests that reach your Public URL. Here is that delivery, with Stripe's sender address (one of its webhook IPs↗), the full Stripe-Signature header and a Content-Length of 3,963 bytes:

Replay sends a captured request to your handler again, and Copy as exports it as cURL, HAR, HTTP, JSON or Markdown. The traffic commands do the same from a terminal:
localcan traffic ls --host clever-eagle
localcan traffic get 11ed9d9b --format curl > replay.sh
A replay carries the original t= timestamp inside its signature, and Stripe's libraries reject a timestamp more than 5 minutes old↗ by default. The same capture, replayed 21 seconds and 361 seconds after Stripe signed it:
age: 21s
$ sh replay.sh
{"received":true}
age: 361s
$ sh replay.sh
Webhook Error: Timestamp outside the tolerance zone
Replay is for the minutes while you fix a handler. For an older event, have Stripe sign a fresh delivery, as the next section shows.
How Do I Send a Stripe Test Webhook?
There are three ways, depending on what you need.
A new event. Run stripe trigger <event>. It runs a fixture in your sandbox, and every listener and endpoint subscribed to that event type receives the result.
The same event, signed again. Open the event in the Dashboard and click Resend, which works for 15 days after the event was created, or run stripe events resend, which works for 30 days (Stripe's manual retries↗).
Dashboard Resend needs a registered endpoint. The CLI command takes one with --webhook-endpoint, and without the flag it resends to your running stripe listen session (stripe events resend reference↗). This ran six minutes after the original delivery, once the replay above had failed:
stripe events resend evt_1USDOO7TSJOOeW4ppQci0XeY --webhook-endpoint=we_1USDO97TSJOOeW4pz2akQ7Hl
The resent delivery kept the event ID and its created time, arrived with a new t=1792404535 in its signature, and verified.
The exact same bytes. Replay it from the traffic inspector, inside the 5-minute window.
Approach 3: LocalCan .local Domains (Offline Development)
Some handler work doesn't need Stripe to send anything: error handling, the logic after a payment, a test suite. A .local domain gives your app a local HTTPS address for that loop.
Setting Up a .local Domain
When you add an app in the desktop app, LocalCan gives it a local domain such as https://my-app.local, and localcan http 3000 prints one too. Trust LocalCan's root certificate once in Settings so browsers accept the HTTPS (see the quick start). The name resolves on your machine and your local network over mDNS.
Replaying Events Locally
Save a payload to a file, such as a body copied from the traffic inspector or this trimmed one:
{
"id": "evt_1234567890",
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_abc123",
"payment_status": "paid",
"customer": "cus_xyz789",
"metadata": {
"plan": "pro",
"user_id": "user_abc"
}
}
}
}
Then sign it the way Stripe would and send it. The stripe-node library ships generateTestHeaderString for this, so your handler's signature check runs instead of being switched off:
import { readFileSync } from 'node:fs';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const [file, url] = process.argv.slice(2);
const payload = readFileSync(file, 'utf8');
// Signs the payload the way Stripe does, with a fresh t= timestamp.
const header = stripe.webhooks.generateTestHeaderString({
payload,
secret: process.env.STRIPE_WEBHOOK_SECRET,
});
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header },
body: payload,
});
console.log(res.status, await res.text());
Run it with the same STRIPE_WEBHOOK_SECRET your handler uses. An unsigned curl of the same file is rejected:
$ node replay-fixture.js test/fixtures/checkout-completed.json http://localhost:3000/api/webhooks/stripe
200 {"received":true}
$ curl -s -X POST http://localhost:3000/api/webhooks/stripe -H "Content-Type: application/json" -d @test/fixtures/checkout-completed.json
Webhook Error: No stripe-signature header value was provided.
The script works against https://my-app.local too when you add Node's --use-system-ca↗ flag (Node 22.15 or later), which makes Node trust LocalCan's root certificate from the operating system's certificate store.
Webhook Signature Verification: Getting It Right
Stripe signs every delivery with the endpoint's secret, and your handler should check that signature before it acts on the event. The check usually fails for one of three reasons: the wrong secret, a changed body or a stale timestamp.
Stripe signs the t= timestamp plus the raw request body, and it sends that body pretty-printed with two-space indents. A body your framework parsed and serialized again won't match, and stripe-node refuses a parsed object outright:
Webhook Error: Webhook payload must be provided as a string or a Buffer (https://nodejs.org/api/buffer.html) instance representing the _raw_ request body.Payload was provided as a parsed JavaScript object instead.
Signature verification is impossible without access to the original signed material.
Express
// WRONG — body-parser modifies the raw body
app.use(express.json());
app.post('/webhooks/stripe', handler);
// RIGHT — use express.raw() for the webhook route
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
handler
);
Next.js (App Router)
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // Raw body, not .json()
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
// Handle event...
return NextResponse.json({ received: true });
}
Common Pitfalls
Using the wrong secret. The CLI's whsec_ and your endpoint's are different values. In the run, stripe listen and a registered endpoint delivered the same checkout.session.completed to one handler that held the endpoint's secret:
2026-10-19 12:02:08 --> checkout.session.completed [evt_1USDOO7TSJOOeW4ppQci0XeY]
2026-10-19 12:02:08 <-- [400] POST http://localhost:3000/api/webhooks/stripe [evt_1USDOO7TSJOOeW4ppQci0XeY]
The CLI's copy failed with "No signatures found matching the expected signature for payload", and the endpoint's copy, which came through the Public URL, verified.
Body parsing middleware. Any middleware that reads req.body before signature verification breaks it. This includes express.json(), body-parser, and some framework-level parsers.
Proxies in front of the handler. A reverse proxy or tunnel has to pass the body through byte for byte. LocalCan adds x-forwarded-*, x-real-ip and lc-ipcountry headers and leaves the body untouched, which is why the endpoint delivery above verified.
Stale timestamps. A replay older than 5 minutes fails the timestamp check (see Inspecting Traffic). Resend it from Stripe instead.
Which Approach Should You Use?
| Scenario | Approach | Why |
|---|---|---|
| Iterating on handler logic | Stripe CLI | No endpoint to register, stripe trigger on demand |
| Your own checkout page, Stripe only | Stripe CLI | The listener forwards every sandbox event, test checkouts included |
| Delivery log, retries, Dashboard Resend | LocalCan Public URL | Only a registered endpoint has them |
| Stripe and other webhook providers | LocalCan Public URL | One URL for every provider |
| Offline handler work | Signed fixtures on a .local domain | No call to Stripe at all |
| CI/CD pipeline | Signed fixtures + mocks | Reproducible, no external deps |
| Team demo to stakeholders | LocalCan Public URL | A URL other people can open |
The Stripe webhooks page compares the Stripe CLI and LocalCan row by row, and the webhook testing guides cover Shopify and other providers. The guide to testing GitHub webhooks locally compares gh webhook forward and LocalCan the same way.
Debugging Checklist
When your webhook isn't working, check these in order:
- Is your server running? Hit the endpoint directly with curl.
- Is the tunnel up? Check the Public URL in LocalCan or with
localcan tunnel ls, then open it in a browser. - Is Stripe sending events? Open your endpoint in Workbench and check its Event deliveries tab, which lists each event as Delivered, Pending or Failed with the HTTP status.
- Is signature verification passing? Log the error from
constructEvent. It names the cause: no matching signature (wrong secret or changed body), a parsed payload, or a timestamp outside the tolerance zone. - Is your handler crashing? Check your server logs. A
500counts as a failed delivery, and Stripe retries it. - Is Stripe retrying? In live mode Stripe retries for up to three days with exponential backoff. In a sandbox it retries three times over a few hours (Stripe's retry rules↗).
Handling Retries and Idempotency
Stripe retries failed webhook deliveries, so your handler can receive the same event more than once. Make your handlers idempotent:
case 'checkout.session.completed': {
const session = event.data.object;
// Check if we already processed this event
const existing = await db.orders.findOne({
stripeSessionId: session.id
});
if (existing) {
console.log(`Already processed session ${session.id}, skipping`);
break;
}
// Process the order
await db.orders.create({
stripeSessionId: session.id,
customerId: session.customer,
status: 'completed',
// ...
});
break;
}
Deduplicate on the data.object ID together with event.type, as the code does with the session ID inside its checkout.session.completed case. Stripe sometimes sends two separate Event objects for one change, so an event ID alone misses those (Stripe's guidance on duplicate events↗). Processing the same event twice is then harmless.
FAQ
How do I test webhook failures locally?
Give a test customer the card 4000 0000 0000 0341. Attaching it succeeds, and every attempt to charge it fails↗, so your own flow produces the failure. For a synthetic one, run stripe trigger invoice.payment_failed. With a Public URL, the failure events reach your local handler through your endpoint.
Where do I find my Stripe webhook secret?
For the CLI, run stripe listen --print-secret. For a registered endpoint, open it in the Webhooks tab in Workbench and click Reveal secret.
Can I test multiple webhook endpoints at once?
The CLI forwards to one --forward-to URL, plus a separate --forward-connect-to URL for Connect events. To feed several local services, register one endpoint per service, each on its own Public URL. Stripe allows up to 16 event destinations↗ per account.
Do I need to handle every Stripe event type?
No. Subscribe each endpoint only to the events your application needs, and narrow the CLI the same way with stripe listen --events checkout.session.completed instead of --all-snapshot.
How do I switch between test mode and live mode webhooks?
Stripe's sandbox (test mode) and live mode have separate webhook endpoints and signing secrets. Use environment variables to switch between them. A Public URL can stay registered in your sandbox while production points at your real domain.
LocalCan is at localcan.com↗, with a Free plan to try the setup above.
