
Testing Stripe Webhooks Locally: A Complete Guide with LocalCan
If you've integrated Stripe, you know the drill. You write your checkout flow, set up a webhook endpoint, deploy to staging... and then spend 20 minutes debugging why your checkout.session.completed event never arrives. Or worse, it arrives but your handler crashes, and you have no idea what the payload looked like.
The problem isn't Stripe. It's the feedback loop. Every time you change your webhook handler, you redeploy, trigger a test event, check the logs, and repeat. It's slow, painful, and unnecessary.
There's a better way: handle Stripe webhooks on your local machine, where you can set breakpoints, see logs instantly, and iterate in seconds. In this post, I'll walk you through three approaches to testing Stripe webhooks locally — the Stripe CLI, LocalCan tunnels, and LocalCan's .local domains — and show you when each one makes sense.
Why Local Webhook Testing Matters
Stripe communicates with your application through webhooks. When a customer completes a payment, updates their subscription, or disputes a charge, Stripe sends an HTTP POST to your endpoint with event data. Your server needs to receive, verify, and process these events.
The challenge: Stripe needs a publicly accessible URL to deliver these events. Your localhost:3000 isn't reachable from the internet.
Developers typically solve this in one of three ways:
- Deploy to staging — slow iteration, shared environment, noisy logs
- Use the Stripe CLI — forwards events locally, but with limitations
- Use a tunnel — expose your local server with a public URL
Each has trade-offs. Let's look at all three.
Approach 1: Stripe CLI (Quick Testing)
The Stripe CLI is Stripe's official tool for local development. It can forward webhook events directly to your local server without a public URL.
Setup
Install the CLI and log in:
# macOS
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
Start forwarding events to your local endpoint:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
You'll see output like:
> Ready! Your webhook signing secret is whsec_1234567890abcdef...
Important: Copy that whsec_ signing secret. It's different from your dashboard webhook secret — the CLI uses its own for local forwarding.
Triggering Test Events
In another terminal, trigger specific events:
# Simulate a successful checkout
stripe trigger checkout.session.completed
# Simulate a subscription cycle
stripe trigger customer.subscription.created
# Simulate a payment failure
stripe trigger invoice.payment_failed
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
The CLI is great for quick, isolated testing. You can trigger specific events and see how your handler responds. But it has limitations:
- Synthetic events only.
stripe triggersends pre-built fixtures, not real data from your Stripe account. The customer IDs, prices, and metadata won't match your actual setup. - No real payment flow. You can't test your actual checkout page → webhook → fulfillment pipeline end-to-end.
- Temporary signing secret. The
whsec_secret changes every session. You need to update your env vars each time.
For full end-to-end testing with real Stripe data, you need a tunnel.
Approach 2: LocalCan Public Tunnels (End-to-End Testing)
A tunnel exposes your local server to the internet through a public URL. This lets Stripe deliver real webhook events to your machine — from actual checkouts, not synthetic fixtures.
Setting Up a Tunnel with LocalCan
In LocalCan, create a new domain pointing to your local server:
- Open LocalCan and click Add Domain
- Set the target to
localhost:3000(or whatever port your app runs on) - Enable Public URL
LocalCan generates a persistent public URL like https://abc123.localcan.dev. This URL stays the same across restarts, so you don't have to reconfigure Stripe every session.
Configuring Stripe to Use Your Tunnel
Go to your Stripe Dashboard → Webhooks↗ and add a new endpoint:
- Endpoint URL:
https://abc123.localcan.dev/api/webhooks/stripe - Events to listen to: Select the events your app handles (e.g.,
checkout.session.completed,customer.subscription.updated,invoice.payment_failed)
Copy the signing secret from the webhook details page and set it in your environment:
STRIPE_WEBHOOK_SECRET=whsec_your_dashboard_secret_here
The Full Flow
Now you can test the complete pipeline:
- Open your app's checkout page in the browser
- Complete a purchase using a Stripe test card↗ (
4242 4242 4242 4242) - Stripe processes the payment and sends
checkout.session.completedto your tunnel URL - The event arrives at your local server with real data — your actual customer, price, and metadata
- Your handler processes it, you see logs in real-time, and you can set breakpoints
This is the closest you can get to production without deploying. You're testing with real Stripe events, real data, and your actual handler code.
Inspecting Traffic
One major advantage of LocalCan over the Stripe CLI: traffic inspection. LocalCan's built-in inspector shows every request hitting your tunnel — headers, body, response, and timing. When a webhook fails, you can see exactly what Stripe sent and what your server returned.
This is invaluable when debugging signature verification issues. You can inspect the raw request body, compare headers, and verify that your middleware isn't modifying the body before it reaches the signature check.
Approach 3: LocalCan .local Domains (Offline Development)
Sometimes you don't need Stripe to send events at all. Maybe you're building the UI for post-payment screens, testing error handling, or working on a plane. LocalCan's .local domains let you develop with HTTPS locally — no internet required.
Setting Up a .local Domain
In LocalCan:
- Click Add Domain
- Set the domain to something like
myapp.local - Point it to
localhost:3000 - HTTPS is enabled automatically
Now https://myapp.local serves your local app with a valid certificate. No tunnel, no internet, no Stripe webhook configuration needed.
Replaying Events Locally
Combine .local domains with saved webhook payloads for fully offline testing. First, capture a real webhook payload using the Stripe CLI or your tunnel logs. Save it to a file:
{
"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 replay it against your local server:
curl -X POST https://myapp.local/api/webhooks/stripe \
-H "Content-Type: application/json" \
-d @test/fixtures/checkout-completed.json
Note: This skips signature verification. For local replay testing, you can conditionally disable it in development:
let event;
if (process.env.NODE_ENV === 'development' && !req.headers['stripe-signature']) {
// Local replay — skip verification
event = JSON.parse(req.body);
console.warn('⚠️ Skipping signature verification (dev mode)');
} else {
// Production / tunnel — verify signature
const sig = req.headers['stripe-signature'];
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
}
This gives you a fast local loop for handler logic without touching Stripe at all.
Webhook Signature Verification: Getting It Right
Signature verification is the #1 source of webhook bugs. Stripe signs every event with your endpoint's secret, and your server must verify that signature before processing the event. If verification fails, you should reject the event.
The most common mistake: your framework parses the request body before you can verify the signature.
Stripe's signature is computed over the raw request body. If your middleware parses it as JSON first, the re-serialized body won't match, and verification fails silently.
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 Stripe CLI generates its own whsec_ secret. Your dashboard webhook has a different one. Make sure you're using the right secret for the right context.
Body parsing middleware. Any middleware that touches req.body before signature verification will break it. This includes express.json(), body-parser, and some framework-level parsers.
Proxy headers. If you're behind a reverse proxy (nginx, Cloudflare), make sure it's not modifying the request body or stripping headers. LocalCan preserves headers and body content by default.
Which Approach Should You Use?
Here's how I think about it:
| Scenario | Approach | Why |
|---|---|---|
| Quick handler testing | Stripe CLI | Fast, no config, synthetic events |
| End-to-end checkout flow | LocalCan tunnel | Real Stripe events, real data |
| UI/error handling work | LocalCan .local domain | Offline, fast, no Stripe needed |
| CI/CD pipeline | Saved fixtures + mocks | Reproducible, no external deps |
| Team demo to stakeholders | LocalCan tunnel | Shareable URL, real payment flow |
In practice, I use all three depending on the task. The Stripe CLI for quick iteration on handler logic, tunnels for end-to-end testing before deploying, and .local domains for everything else.
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 active? Check LocalCan's status or try the public URL in a browser.
- Is Stripe sending events? Check the Stripe Dashboard → Events↗ log.
- Is signature verification passing? Log the error from
constructEvent. It's usually a wrong secret or parsed body. - Is your handler crashing? Check your server logs. An unhandled exception returns a 500, and Stripe will retry.
- Is Stripe retrying? Stripe retries failed deliveries for up to 3 days. Check the webhook attempt logs in the dashboard.
Handling Retries and Idempotency
Stripe retries failed webhook deliveries. Your handler might receive the same event multiple times. This is by design — it ensures events aren't lost due to temporary failures.
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;
}
Use the Stripe event ID or session ID as a deduplication key. This way, processing the same event twice is harmless.
Conclusion
Testing Stripe webhooks locally doesn't have to be painful. The Stripe CLI gives you quick, synthetic event testing. LocalCan tunnels give you real end-to-end flows with actual Stripe events. And .local domains let you build and debug offline with saved payloads.
The key insight: match your testing approach to what you're actually working on. Don't spin up a full tunnel when you just need to test a switch statement. Don't rely on synthetic events when you need to verify the real checkout-to-fulfillment pipeline.
Pick the right tool for the moment, and your webhook development goes from 20-minute deploy cycles to instant local feedback.
FAQ
How do I test webhook failures locally?
Use Stripe test cards that trigger specific outcomes. 4000 0000 0000 0341 triggers a card decline, which generates invoice.payment_failed. With a tunnel, you'll receive the real failure event on your local machine.
Can I test multiple webhook endpoints at once?
Yes. The Stripe CLI supports multiple --forward-to flags. With LocalCan, create separate domains or use path-based routing to direct events to different local services.
What about webhook event ordering?
Stripe doesn't guarantee event ordering. invoice.paid might arrive before checkout.session.completed. Design your handlers to be order-independent — check the current state rather than assuming a sequence.
Do I need to handle every Stripe event type?
No. Only subscribe to events your application needs. Return a 200 response for any event you receive, even unhandled ones, to prevent Stripe from retrying them unnecessarily.
How do I switch between test mode and live mode webhooks?
Stripe test mode and live mode have separate webhook endpoints and signing secrets. Use environment variables to switch between them. Your tunnel URL can stay the same — just update which Stripe mode points to it.
For local domains and public URLs for your dev projects, check out LocalCan↗ — test webhooks, OAuth flows, and share projects with persistent URLs. No CLI needed.