How to Test Webhooks Locally: Stripe CLI, Tunnels, and Replays
Webhook providers can't reach localhost, so local testing needs a forwarder or a tunnel. How to use provider CLIs like stripe listen, general tunnels like ngrok and Cloudflare Tunnel, request-capture tools, and fixture replays in automated tests — plus the signature-verification gotchas.
Webhooks are HTTP requests a service sends to your server when something happens — a payment succeeded, a repo got a push, a form was submitted. (What is a webhook?) The problem when developing: Stripe's servers can't send anything to http://localhost:3000 on your laptop. (What is localhost?)
There are four good ways to close that gap, and you'll likely use more than one.
1. The provider's CLI (best when available)
Several providers ship a CLI that receives events on their side and forwards them to your local server. Stripe's is the best-known:
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
It prints a webhook signing secret (whsec_…) for this session. Put that in your local .env — it's different from your dashboard endpoint's secret, and using the wrong one is the #1 cause of "signature verification failed" locally.
Trigger test events without clicking through checkout:
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
You can also forward only certain events (--events checkout.session.completed,customer.subscription.updated) and resend past events from the dashboard. Shopify, GitHub (gh webhook forward), Twilio and others have similar tools.
2. A tunnel: give localhost a public URL
When there's no CLI, a tunnel gives your local port a temporary public HTTPS address:
ngrok http 3000
# → https://a1b2-….ngrok-free.app → http://localhost:3000
cloudflared tunnel --url http://localhost:3000
# → https://random-words.trycloudflare.com
Paste that URL into the provider's webhook settings (as a test endpoint). Notes:
- Free URLs often change each time you restart; you'll need to update the provider. Paid/named tunnels give a stable hostname.
- Your local server is now reachable from the internet while the tunnel runs. Don't expose admin endpoints, and stop the tunnel when you're done.
- ngrok's local inspector (
http://localhost:4040) shows every request and lets you replay it — very handy.
3. Capture first, then replay
Tools like webhook.site, RequestBin-style services or your tunnel's inspector capture the raw request: headers and body exactly as sent. That's useful to:
- See the real payload shape before writing code.
- Save a copy as a fixture for tests.
- Debug "it works in tests but not with the real provider."
Don't send production webhooks with real customer data to third-party capture services.
4. Fixtures in automated tests
Your CI can't run a tunnel to Stripe — and shouldn't depend on one. Save representative payloads as JSON fixtures and test your handler directly. The trick is signing them like the provider does, so the verification code is tested too:
import Stripe from 'stripe'
import payload from './fixtures/checkout-session-completed.json'
const secret = 'whsec_test_secret'
const body = JSON.stringify(payload)
const header = Stripe.webhooks.generateTestHeaderString({ payload: body, secret })
const res = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', header)
.set('content-type', 'application/json')
.send(body)
expect(res.status).toBe(200)
expect(await db.subscription.findFirst({ where: { userId: 'u_1' } })).not.toBeNull()
Add tests for the cases that matter: duplicate delivery (send the same event twice — it must be processed once), out-of-order events, and a bad signature (must be rejected). (Unit vs integration vs E2E tests)
Signature verification gotchas
Most local webhook pain is signature failures:
- Raw body required. The signature is computed over the exact bytes sent. If your framework parses JSON first and you re-serialise it, the bytes differ. Read the raw body for webhook routes (
await req.text()in Next.js route handlers;express.raw({ type: 'application/json' })for that route in Express). - Wrong secret — CLI session secret vs dashboard endpoint secret vs test-mode vs live-mode secret.
- Clock skew — signatures include a timestamp with a tolerance; a badly wrong system clock fails verification.
- Proxies modifying the body — some middleware or tunnels re-encode content.
(Handle webhooks reliably covers signatures, idempotency and retries in depth.)
Respond fast, process later
Providers time out and retry if you're slow. Locally everything's quick; in production a slow handler causes duplicate deliveries. Verify, record the event, return 2xx, and do heavy work in a background job. (Background jobs)
The summary
- Use the provider's CLI (
stripe listen) when there is one — and its session signing secret. - Otherwise use a tunnel (ngrok, Cloudflare Tunnel) and its request inspector.
- Capture real payloads as fixtures; sign them in tests and cover duplicates and bad signatures.
- Verify against the raw body with the right secret; respond quickly.
EasySpawn gives your app a persistent server with a public HTTPS URL on your own domain, so webhooks reach your dev and staging environments directly — no tunnels to restart. See how it works or join the waitlist.
Related: Handling Webhooks Reliably · What Is a Webhook? · Add Stripe Payments to an AI-Built App · Stripe Subscriptions Explained
Keep reading
Playwright Tutorial: End-to-End Tests That Aren't Flaky
Playwright drives real browsers to test your app the way users use it. Install it, write your first test, use role-based locators and auto-waiting assertions, log in once and reuse the session, run against a dev server, debug with UI mode and traces, and run it in CI.
ESM vs CommonJS: import vs require and the Errors Between Them
JavaScript has two module systems. CommonJS uses require and module.exports; ES modules use import and export. How Node decides which a file is, and how to fix 'Cannot use import statement outside a module', 'require is not defined', ERR_REQUIRE_ESM and __dirname is not defined.