Testing Stripe webhooks locally is one of those problems where the tooling has expanded over the last five years and most articles you'll find online still describe a 2020 workflow. There are now four serious ways to do it — Stripe CLI, ngrok, capture-and-forward services, Stripe's dashboard test events — each with different trade-offs, and the right answer depends on what specifically you're testing: signature verification, handler logic, edge cases like disputes, or end-to-end checkout flows.
I run WebhookWhisper, so yes, I have an opinion. But I've used all four extensively, and the honest answer is that they fit different jobs. This post covers each option, when each is the right tool, and the full workflow for the option I use most often (capture-and-forward, because it scales from local testing to actual production debugging without changing tools).
Why testing Stripe webhooks is harder than testing anything else
The unique constraints, in priority order:
The trigger is a real action. A genuine payment_intent.succeeded requires a real Stripe charge. In test mode, you can use test card numbers, but you still have to drive a checkout flow to fire each event. Some events (chargebacks, disputes, certain subscription state changes) can't be triggered by the test API at all and have to be simulated.
The signature is over raw bytes. Half of all Stripe webhook bugs come from the raw-body issue (express.json() parses the body before your handler reads it; the bytes change; HMAC fails). Testing locally has to either use a test fixture with a precomputed valid signature, use Stripe's tooling that signs with a CLI-specific secret, or use a forwarder that passes through the original signed bytes from a real Stripe delivery. The error class is stripe-signature-mismatch when verification fails downstream.
Stripe times out at 30 seconds. If your handler does heavy synchronous work and you only test with the local server idle, you won't catch the timeout-induced retry behavior under load. Real testing needs to include the slow-path.
Stripe retries for 3 days. Bugs that survive your test pass can still fire repeatedly in production after the deploy. Idempotency testing can't be "I sent the event once" — it has to be "I sent the same event twice." See our Stripe provider page for the full retry schedule.
Each of the four testing approaches addresses some subset of these constraints, with different friction.
Option 1 — Stripe CLI (stripe listen + stripe trigger)
The closest to "official" tooling. Install the Stripe CLI binary, authenticate against your Stripe account, run two commands:
# In one terminal: forward webhooks to your local server
stripe listen --forward-to localhost:3000/webhooks/stripe
# Output:
# Ready! Your webhook signing secret is whsec_abcdef... (this is the CLI's
# own webhook secret — different from your Dashboard endpoint's secret)
# 2026-05-04 10:23:15 --> payment_intent.created [evt_1...]
# 2026-05-04 10:23:15 <-- [200] POST http://localhost:3000/webhooks/stripe
The CLI opens a persistent outbound connection to Stripe's edge. When events fire on your account (test or live mode), Stripe sends them down that connection, the CLI POSTs them at your localhost. No tunnel. No firewall change.
To fire test events on demand:
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
The CLI ships with curated payloads for ~80 event types. Each fires a real signed webhook that exercises your end-to-end signature verification.
When the Stripe CLI is the right tool:
- You're actively writing handler code on your laptop and want sub-second iteration.
- You only need to test the canonical event shapes (the ones
stripe triggerships with). - You don't mind running a persistent terminal process and remembering the command.
Where the Stripe CLI breaks:
- Your laptop sleeps and the CLI exits. Events delivered during the gap aren't captured.
- You want to test against a real Dashboard-registered endpoint (the CLI's signing secret is separate from your Dashboard endpoint's secret — they're not interchangeable).
- You need to test edge cases that
stripe triggerdoesn't ship a fixture for (some dispute and refund flows, certain subscription state transitions). - You want a permanent URL that lives in the Stripe Dashboard and forwards reliably, including when your laptop is closed.
Option 2 — ngrok (or any HTTP tunnel)
The classic. Run ngrok http 3000, get a public URL, paste it into the Stripe Dashboard as your webhook endpoint. Stripe POSTs to the public URL, ngrok pipes the request to your localhost.
ngrok http 3000
# Output:
# Forwarding https://e3a7-2401-4900-1234.ngrok-free.app -> http://localhost:3000
When ngrok is the right tool:
- You need a real Stripe Dashboard endpoint (not the CLI), so you can test with the actual signing secret you'll use in production-like mode.
- You want to test the integration with other Stripe features that need a real reachable endpoint (some Connect flows, etc.).
- You're already paying for ngrok or have it set up.
Where ngrok breaks:
- The free tier rotates URLs every restart. You re-paste into the Stripe Dashboard. Then you also re-paste into GitHub, Shopify, Twilio if you're integrating those too.
- Your laptop sleeps; events fail; Stripe retries; eventually retries exhaust and events are lost.
- You can't share the URL with a teammate — it's tied to your laptop.
- For long-running integration work (a feature taking days), the constant URL rotation becomes a real cost.
Cloudflare Tunnel and Tailscale Funnel are the same model with stable URLs and no time limit on the free tier — better choices than ngrok in most cases for serious work, but the model is the same: your laptop is the public web server while the tunnel is open.
Option 3 — Capture-and-forward services
The category that includes WebhookWhisper, Hookdeck, Svix, Webhookify. Provider POSTs to a permanent public URL that lives in someone else's database; that service stores every event durably for some retention window; that service forwards to your handler on whatever schedule you choose, including to your localhost via an outbound connection your CLI or browser tab opens.
The setup, once:
- Sign up for a free WebhookWhisper account (or use the guest mode with no signup).
- Create an endpoint. You get a permanent URL like
https://webhookwhisper.com/hook/abc123xyz. - Paste that URL into the Stripe Dashboard as a webhook endpoint. Copy the
whsec_signing secret Stripe shows you. - Add a forwarding rule: target
http://localhost:3000/webhooks/stripe. - Trigger events from the Stripe Dashboard's "Send test webhook" button or from your test code.
The events flow Stripe → WebhookWhisper (durable capture) → your localhost handler (with the original Stripe signature intact). If your laptop is closed, the events sit in WebhookWhisper's queue until you wake up. If your handler returns 500, you can fix it and replay the events from the dashboard.
When this is the right tool:
- Long-running integration work. The URL is permanent; you paste it once.
- You want to test with the real Stripe Dashboard endpoint and the real production-style signing secret.
- You want events captured durably so you can replay them after fixing handler bugs.
- You want to share the URL with a teammate or a CI environment.
- You eventually want to use the same setup in production for real forwarding, not just testing.
Where this breaks:
- You're behind a corporate VPN or air-gapped network where outbound connections to a third party are blocked.
- You need to test events that shouldn't ever leave your machine (rare, but for certain compliance scenarios).
Option 4 — Stripe Dashboard test events (no infrastructure at all)
The fastest way to fire a single event for a quick check. Go to the Stripe Dashboard → Webhooks → click your endpoint → "Send test webhook" → pick an event type → click Send. Stripe fires the event at your registered endpoint. No CLI, no tunnel, no setup.
The constraint is you need a registered endpoint. If you've used Option 2 or Option 3 above, you have one. If not, this option doesn't help by itself — you need the URL infrastructure first.
Use this as the trigger mechanism on top of whichever URL infrastructure you've chosen, especially when you want to verify a real Dashboard signature flow end-to-end.
The full workflow with capture-and-forward (the one I use)
For long-running Stripe integration work — anything more than a 30-minute one-off — this is the workflow that minimizes friction. I'm describing the WebhookWhisper version because it's what I built; substitute Hookdeck or Svix and the shape is identical.
Step 1 — Get a permanent public URL
Go to webhookwhisper.com, click "Create Free Endpoint." Or sign up for a free account if you want a named, permanent endpoint:
https://webhookwhisper.com/hook/abc123xyz
This URL doesn't change. Bookmark it. Paste it into Stripe's Dashboard once.
Step 2 — Register it in Stripe
Stripe Dashboard → Developers → Webhooks → "Add endpoint":
- Endpoint URL: paste your WebhookWhisper URL.
- Events to send: select what you actually need. For a payments integration:
payment_intent.succeeded,payment_intent.payment_failed,checkout.session.completed,customer.subscription.created,customer.subscription.deleted,invoice.payment_failed,charge.dispute.created. Don't blanket-subscribe to "all events" in production — you'll get a flood and your idempotency table will grow unnecessarily. - Click "Add endpoint." Stripe immediately fires a test ping; you'll see it arrive in WebhookWhisper within a second.
- Copy the Signing secret shown after creation (starts with
whsec_). Set this asSTRIPE_WEBHOOK_SECRETin your local.env.
Step 3 — Set up forwarding to localhost
In WebhookWhisper, open your endpoint, go to the Forwarding tab, click "Add rule":
- Target URL:
http://localhost:3000/webhooks/stripe(or whatever port and path your dev server uses). - Method: POST (default).
- Save.
Now every event Stripe sends to your WebhookWhisper URL is automatically forwarded to your localhost — with all original headers intact, including Stripe-Signature. Your handler verifies the real Stripe signature exactly as it would in production.
Step 4 — Implement the handler with proper raw-body handling
The non-negotiable: pass the raw bytes to constructEvent, not a parsed JSON object.
import Stripe from 'stripe'
import express from 'express'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
const app = express()
// ⚠️ Route-specific raw body — must come BEFORE express.json()
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch (err) {
console.error('Signature verification failed:', err.message)
return res.status(400).send(`Webhook Error: ${err.message}`)
}
// Acknowledge fast, then process
res.json({ received: true })
// Enqueue actual work — don't block the response
await queue.add('process-stripe-event', { eventId: event.id })
}
)
app.use(express.json()) // global JSON for everything else
If your handler does idempotency checks before processing (it should — Stripe's at-least-once delivery means you'll receive duplicates), do it inside the queue worker, not in the HTTP handler. Acknowledge the receive as fast as possible.
Detailed coverage of the raw-body issue, the seven verification failures, and language-specific implementations is in the dedicated Stripe webhook signature verification guide.
Step 5 — Trigger events to test
Three trigger options, fastest to slowest:
From WebhookWhisper's Test Sender. The fastest. Open the Stripe webhook testing page, pick an event type, click Send. Real Stripe-shape payload arrives at your handler in milliseconds. Caveat: the Test Sender doesn't sign with a real Stripe signature — use it for handler-logic iteration but not for testing signature verification end-to-end.
From the Stripe Dashboard. Open your webhook endpoint in the Dashboard, click "Send test webhook," pick an event type. Stripe fires a real signed payload at WebhookWhisper, which forwards to your handler. End-to-end including signature.
From the Stripe API in test mode. If you're testing a flow (a checkout session, a subscription creation), drive it through the API or the test-mode UI. Real downstream events fire naturally.
The events you should test for any payments integration, beyond the happy payment_intent.succeeded:
| Event | Why this matters |
|---|---|
payment_intent.payment_failed | Does your retry-payment flow work? Does the user get notified? |
customer.subscription.created | Does access provisioning work? Onboarding email? |
customer.subscription.deleted | Does access actually get revoked? Do trials handle correctly? |
invoice.payment_succeeded | Updates billing record. Often missed in initial integration. |
invoice.payment_failed | Does dunning kick in? Email to update card? |
checkout.session.completed | Most reliable fulfilment trigger; some teams use this instead of payment_intent.succeeded |
charge.dispute.created | Does someone get paged? Dispute evidence preparation? |
charge.refunded | Does the refund propagate to your own ledger? |
Most teams ship after testing only payment_intent.succeeded and discover the bugs in the others under real customer load. Don't. The security side of "what could a malicious caller do to your handler?" is covered separately in webhook security best practices.
Step 6 — Inspect and replay
This is the operational superpower of capture-and-forward over CLI or tunnel approaches. WebhookWhisper stores every event (7 days on Free, 14 on Starter, 30 on Pro). The inspector shows:
- Full raw request body (with a "Copy raw" button — useful for hand-checking signatures).
- Every header, including
Stripe-Signature. - Forward delivery log: HTTP status your handler returned, response body, round-trip duration, which forward attempt this was.
- "Replay" button on every event — re-fire the exact original payload at your handler. Useful when you've fixed a handler bug and want to retry the events that failed during the bug window.
The replay flow is what saves you the day a bug shipped to production and you missed events. Provider retries cover some of it (Stripe retries for 3 days); replay from the inspector covers the rest, including events that already exhausted retries.
Testing in CI
For CI pipelines, you don't want any external dependencies — no Stripe API calls, no WebhookWhisper, no network. Use the Stripe SDK's test-header generator to synthesize valid signatures for fixture payloads:
// Node — generate a valid Stripe-Signature header for tests
import stripe from 'stripe'
const payload = JSON.stringify({
id: 'evt_test',
type: 'payment_intent.succeeded',
data: { object: { id: 'pi_test', amount: 1000, currency: 'usd' } }
})
const secret = 'whsec_test_secret_for_ci'
const timestamp = Math.floor(Date.now() / 1000)
const signature = stripe.webhooks.generateTestHeaderString({
payload, secret, timestamp,
})
// Now your test can POST `payload` with `Stripe-Signature: ${signature}`
// at your handler and assert it returns 200.
const response = await fetch('http://localhost:3000/webhooks/stripe', {
method: 'POST',
headers: { 'Stripe-Signature': signature, 'Content-Type': 'application/json' },
body: payload,
})
expect(response.status).toBe(200)
The same payload with a deliberately wrong secret should produce a 400. The same payload twice should produce a 200 the first time and a 200-with-deduplicated-flag the second time, if your idempotency check is right. Tests for these three cases plus the happy path cover most of what you need at the unit-test level.
For end-to-end CI tests against a real Stripe test-mode account, the approach is to provision a test-mode webhook endpoint dynamically (Stripe's API supports this), point it at a CI-accessible URL (an ngrok tunnel from the CI runner, or a temporary WebhookWhisper endpoint), trigger events with the API, and assert the handler processed them. This is heavier but catches integration issues unit tests miss.
Common Stripe webhook testing mistakes
Testing only the happy path
The most common — covered above. Test failures, disputes, subscription deletions, and refund flows in addition to payment_intent.succeeded.
Using express.json() globally before the webhook route
The raw-body issue. Your global JSON middleware parses the body before the webhook route runs; signature verification fails. Either use express.raw() on the specific route, or place the webhook route before the global middleware.
Skipping the timestamp tolerance check
Stripe's signature includes a timestamp, and constructEvent rejects events older than 300 seconds by default. If you replay events more than 5 minutes after they were originally sent, you need to pass a longer tolerance. Don't disable it entirely in production.
Using the Stripe CLI's secret with a Dashboard endpoint
The CLI prints its own whsec_ when you run stripe listen — that secret only verifies events the CLI forwards. The Dashboard endpoint has its own secret. They are not interchangeable. If verification works with the CLI but fails when events come from the Dashboard (or vice versa), you're using the wrong secret.
Testing locally without idempotency
Stripe's at-least-once delivery means duplicates in production. If your tests don't fire the same event twice, you won't catch the bug where your idempotency check is wrong.
Frequently asked questions
Can I test Stripe webhooks without a Stripe account?
You can test handler logic with synthesized payloads (use the Test Sender or generate signatures with the SDK helper). You can't test the actual end-to-end flow with real Stripe signatures without a Stripe account in test mode, which is free to create.
Should I use the Stripe CLI or a capture-and-forward service?
CLI for sub-second iteration on a single laptop, capture-and-forward for any work that spans more than a few hours, multiple developers, multiple providers, or eventual production use. They aren't mutually exclusive — many teams use the CLI for rapid local iteration and a forwarding service for staging and production.
Do I need to test signature verification specifically, or is "the SDK works" enough?
You should test it. Most signature failures come from middleware or proxy interactions, not from the SDK call itself. A test that POSTs a payload with a deliberately wrong secret and asserts a 400 catches the case where someone introduces a global JSON middleware that breaks the raw-body flow.
How do I test the timeout-and-retry path locally?
Add a deliberate delay in your handler (e.g. await sleep(35000)), trigger an event, observe Stripe's retry behavior in the Dashboard's delivery log. Or in your queue worker, throw an error on the first attempt and assert the second attempt processes correctly. Don't deploy the deliberate delay to production.
Can I share my WebhookWhisper URL with my team?
Yes — WebhookWhisper endpoints are URLs that anyone can POST to. For team workflows, the typical setup is one endpoint per environment (dev, staging, prod), each with its own forwarding rule. Multiple developers can register the same dev endpoint with their own Stripe test-mode accounts and capture events independently in the inspector.
Closing
The summary, by use case: short-term local iteration, use the Stripe CLI; long-running integration work or anything that involves the real Dashboard, use a capture-and-forward service; quick one-off, use the Dashboard's test event button against whatever endpoint you've set up. CI tests should use the SDK's signature generator with synthetic payloads — no network dependency.
If you want the capture-and-forward path running in five minutes, that's exactly what WebhookWhisper's free tier exists for — paste a permanent URL into your Stripe Dashboard, point forwarding at your localhost, and every event is captured durably (7 days on Free, longer on paid tiers), replayable on demand. Our free in-browser signature tester is useful when you need to hand-verify what a signature should be for a specific payload.
For deeper coverage of the related concerns: the Stripe signature verification deep dive, cross-provider debugging, retry schedules and idempotency, and forwarding without ngrok.