Test Stripe Webhooks Locally
Why Stripe webhooks need special handling
Stripe sends webhook events when payments succeed, subscriptions change, invoices fail, and dozens of other lifecycle moments. Your handler must verify cryptographic signatures, respond quickly with HTTP 200, and process events idempotently. Testing this locally requires either forwarding events with the Stripe CLI or giving Stripe a public HTTPS URL that reaches your machine.
Both paths are documented below. Choose based on whether you need dashboard-level integration testing or fast handler iteration.
Start from the Test Webhooks Locally hub if you are comparing tunnel vs CLI across multiple providers.
Option 1: Stripe CLI (fastest for development)
For day-to-day handler development, the Stripe CLI is the quickest path. It receives events from Stripe and forwards them to localhost — no public URL required.
Install and authenticate:
stripe login
Forward events to your handler:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI prints a webhook signing secret:
Ready! Your webhook signing secret is whsec_xxxxxxxx
Use this whsec_ value as STRIPE_WEBHOOK_SECRET in your local environment.
Trigger test events:
stripe trigger payment_intent.succeeded
When to use the CLI:
- Building or debugging handler logic
- Testing signature verification locally
- Rapid event triggering without dashboard configuration
Option 2: LocalToLink (full HTTPS URL testing)
Use a tunnel when you need to test with the real Stripe Dashboard webhook endpoint — for example, verifying your production-like URL format, testing Stripe's "Send test webhook" button, or validating webhook endpoint configuration before deploy.
1. Start your app:
npm run dev
2. Start the tunnel:
npx localtolink --port 3000
3. Register in Stripe Dashboard → Developers → Webhooks → Add endpoint:
| Field | Value |
|---|---|
| Endpoint URL | https://k7m2x9p4q1.localtolink.com/api/webhooks/stripe |
| Events | Select the events your handler processes |
| API version | Match your production version |
Replace k7m2x9p4q1 with the 10-character subdomain LocalToLink prints.
4. Copy the signing secret from the endpoint details page (whsec_...) into your local environment.
When to use a tunnel:
- Testing the exact URL path your production app will use
- Using Stripe Dashboard's "Send test webhook" feature
- End-to-end validation before staging deploy
Verify webhook signatures
Your handler must verify Stripe signatures regardless of approach. Never process unverified payloads.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Handle event.type
return new Response("ok", { status: 200 });
}
Signing secret source:
| Approach | Secret comes from |
|---|---|
| Stripe CLI | Output of stripe listen (whsec_...) |
| Dashboard endpoint | Endpoint details in Stripe Dashboard |
Use the correct secret for the approach you are testing. Mixing them causes verification failures.
Handler best practices
- Return 200 quickly — queue heavy work asynchronously; Stripe retries on timeout
- Use raw body — signature verification requires the unparsed request body
- Handle duplicates — Stripe may deliver the same
event.idmore than once - Log
event.id— helps correlate with Stripe Dashboard → Events
Important notes for LocalToLink
- Webhook POSTs are server-to-server; some automated clients may reach your app differently than a first-time browser visit
- Session limit: 2 hours — restart the tunnel and update the Dashboard URL if testing longer
- LocalToLink does not replay or inspect requests — check Stripe Dashboard → Events for delivery status and resend failed events
- Random subdomain — update the Dashboard endpoint URL when you start a new session
- Rate limit 10 req/s — sufficient for webhook testing; not for simulating high-volume traffic
Troubleshooting
| Problem | Fix |
|---|---|
Webhook signature verification failed | Confirm STRIPE_WEBHOOK_SECRET matches the source (CLI vs Dashboard) |
| Dashboard shows failed delivery | Check handler returns 200; inspect local server logs |
| No events received | Verify tunnel is running; confirm URL matches subdomain |
| Session ended | Restart tunnel; update Dashboard endpoint URL |
| Handler receives events but crashes | Check event types you subscribed to match handler cases |
CLI vs tunnel decision matrix
| Need | Stripe CLI | LocalToLink tunnel |
|---|---|---|
| Fast handler iteration | ✓ | |
| Dashboard "Send test webhook" | ✓ | |
| Exact production URL format | ✓ | |
| No account / no public URL | ✓ | ✓ (no LocalToLink account) |
| Request inspector UI | Use Stripe Dashboard Events |
FAQ
Can I use both CLI and tunnel?
Yes, but use separate signing secrets. Do not mix CLI secrets with Dashboard endpoint secrets.
Does Stripe support localhost URLs?
Stripe Dashboard requires HTTPS endpoints reachable from the internet. http://localhost works only with the CLI forwarder, not Dashboard registration.
What about Stripe Connect webhooks?
Same pattern — register the tunnel URL for Connect webhook endpoints in Dashboard or use CLI forwarding.
Related
- Test Webhooks Locally — hub for all providers
- Expose Localhost to the Internet — tunnel basics
- LocalToLink vs ngrok — when to use a different tunnel tool
Try it now
Start a tunnel from your project directory — no account required.
$ npx --yes localtolink@latest