Engineering·5 min read·2025-05-09·Colm Byrne, Technical Product Manager

How to Test Stripe Webhooks Locally: Beyond ngrok

Stripe CLI sends synthetic events. ngrok gives you a real URL that changes every session. HookTunnel gives you a permanent URL that captures events even when your tunnel is offline and lets you replay them when you reconnect.

You're building the Stripe integration. You need to test the customer.subscription.updated event against your local handler. You have three options.

Option A gives you a command-line tool that sends events to localhost. The events are synthetic — Stripe-format JSON, not real events from your Stripe account. The CLI must stay running in a terminal tab.

Option B gives you a real tunnel to localhost. The URL is public, real events flow through it, and you can point your Stripe dashboard at it. The URL is different every session on the free plan. You update the Stripe dashboard on Monday. Tuesday morning you start a new tunnel — new URL. Back to the Stripe dashboard.

Option C gives you a permanent URL that never changes. You configure it in Stripe once. Real events flow through it. If your tunnel is offline when an event arrives, the event is stored and delivered when you reconnect. You can replay any stored event against your local handler on demand.

This is the current state of local Stripe webhook development. Let's go through each option with real commands and an honest assessment of where each one breaks down.

Option A: Stripe CLI

The Stripe CLI is a first-party tool from Stripe for local webhook development. The Stripe CLI is the correct tool for deterministic automated testing — but it sends synthetic events, not real ones from your account. See the Stripe webhook documentation for the full CLI reference, and Stripe webhook best practices for how Stripe recommends handling local development.

# Install (macOS)
brew install stripe/stripe-cli/stripe

# Authenticate
stripe login

# Forward events to your local handler
stripe listen --forward-to http://localhost:3000/webhooks/stripe

The output looks like this:

> Ready! You are using Stripe API Version [2024-06-20]. Your webhook signing secret is whsec_test_abc123...
2026-02-19 14:23:01  --> customer.subscription.updated [evt_1P...]
2026-02-19 14:23:01  <-- [200] POST http://localhost:3000/webhooks/stripe [evt_1P...]

The signing secret shown in the terminal is what you use for stripe.webhooks.constructEvent() during local development — different from your production webhook secret.

You can also trigger specific events without going through the Stripe dashboard:

stripe trigger customer.subscription.updated
stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed

Where Stripe CLI works well: Solo development, isolated unit testing, CI environments where you want deterministic event shapes without network dependencies.

Where Stripe CLI breaks down:

The events are synthetic. stripe trigger customer.subscription.updated sends a Stripe-format payload with placeholder data — it's not generated from your actual Stripe account. If your handler does anything with the customer ID (looks up the customer in your database, cross-references subscription metadata, reads customer email), the synthetic event will have a customer ID that doesn't exist in your system.

The CLI must stay running. If you close the terminal tab, events stop forwarding. There's no persistence — events that arrived while the CLI was down are gone.

You can't replay real events from your test mode Stripe account. If a real webhook fired (customer upgraded, payment failed, trial ended) and your local handler wasn't running, that event is not coming back.

Option B: ngrok

ngrok is the standard tool for exposing localhost to the internet. ngrok gives you real Stripe events — but the URL rotates every session on the free plan, making it a recurring coordination problem for teams. For teams hitting this wall, see webhook revenue leakage for the downstream cost of events that arrive while tunnels are offline.

# Install ngrok
# Download from https://ngrok.com/download

# Expose port 3000
ngrok http 3000

Output:

Forwarding  https://abc123.ngrok-free.app -> http://localhost:3000

You take that URL, open the Stripe dashboard, go to Developers > Webhooks, add the URL, select the events you want, save.

Your local handler now receives real webhook events from your Stripe account. When you trigger a customer upgrade through the Stripe test dashboard, the real customer.subscription.updated event hits your local handler.

Where ngrok works well: Solo development where you need real events, not synthetic ones. Quick integrations where you just need to confirm the handler is wired correctly.

Where ngrok breaks down:

The URL changes every session on the free plan. Every time you run ngrok http 3000, you get a different URL: abc123.ngrok-free.app today, def456.ngrok-free.app tomorrow. Every session requires a Stripe dashboard update. This is a minor annoyance solo, a real coordination problem on a team.

Events are lost when the tunnel is offline. If you close ngrok and a Stripe webhook fires (a real event from a test customer action, a scheduled retry from a previous failure), the event hits the dead URL. Stripe retries, eventually gives up, and the event is gone from your local development environment. You have to reproduce the condition manually to test again.

No persistence means no replay. There's no history of events that came through your ngrok tunnel. If your handler threw an exception on a specific event and you want to reproduce it, you have to recreate the exact condition in Stripe that triggered the event.

The paid plan gives you a static URL. At $10/month per developer, a team of 4 is paying $40/month just to avoid updating the Stripe dashboard every morning.

Option C: HookTunnel

HookTunnel takes a different approach. Instead of a per-session tunnel, you create a named webhook endpoint with a permanent URL. You configure that URL in Stripe once. When your local tunnel is running, events are forwarded to your handler in real time. When your tunnel is offline, events are stored. When you start your tunnel, you see what came in while you were offline and replay it.

# Install the HookTunnel CLI
npm install -g hooktunnel

# Start the tunnel, forwarding your permanent hook ID to localhost
hooktunnel forward --hook your-hook-id --to http://localhost:3000/webhooks/stripe

When you connect, the CLI shows you what arrived while you were offline:

Connected. Hook: https://hooks.hooktunnel.com/h/your-hook-id
3 events captured while offline:
  [evt_1P...] customer.subscription.updated  2026-02-19 09:14:22
  [evt_1P...] customer.subscription.updated  2026-02-19 11:47:03
  [evt_1P...] invoice.payment_succeeded      2026-02-19 14:23:01

Replay all? [y/N]

You replay, your handler processes the real events with real data from your Stripe account, you see the results.

Where HookTunnel fits: Any scenario where you're doing sustained Stripe integration work over days or weeks, where you need real events (not synthetic), and where you're working on a team.

Step-by-step setup for each approach

Stripe CLI setup

# 1. Install
brew install stripe/stripe-cli/stripe   # macOS
# or: https://stripe.com/docs/stripe-cli#install

# 2. Login
stripe login
# Opens browser, authenticate with Stripe account

# 3. Forward webhooks to local handler
stripe listen \
  --forward-to http://localhost:3000/webhooks/stripe \
  --events customer.subscription.updated,invoice.payment_succeeded

# 4. Note the signing secret from output:
# Your webhook signing secret is whsec_test_abc123...

# 5. Use that secret in your handler:
# process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_abc123...'

# 6. Trigger a specific event:
stripe trigger customer.subscription.updated

ngrok setup

# 1. Download from ngrok.com, unzip, add to PATH

# 2. Authenticate (free account required)
ngrok config add-authtoken YOUR_AUTHTOKEN

# 3. Open tunnel
ngrok http 3000

# 4. Copy the https:// URL from output
# Example: https://abc123.ngrok-free.app

# 5. In Stripe dashboard:
#    Developers > Webhooks > Add endpoint
#    URL: https://abc123.ngrok-free.app/webhooks/stripe
#    Events: Select the events you want

# 6. Retrieve your webhook signing secret from Stripe dashboard
# Add to your handler's env vars

# REPEAT steps 3-5 every session on free plan

HookTunnel setup

# 1. Install CLI
npm install -g hooktunnel

# 2. Create a permanent hook endpoint
# (one time, in HookTunnel dashboard or via CLI)
hooktunnel hooks create --name "stripe-local-dev"
# Output: Hook created: https://hooks.hooktunnel.com/h/abc-123-def

# 3. In Stripe dashboard:
#    Developers > Webhooks > Add endpoint
#    URL: https://hooks.hooktunnel.com/h/abc-123-def/webhooks/stripe
#    Events: Select the events you want
# DO THIS ONCE. THE URL NEVER CHANGES.

# 4. Start forwarding
hooktunnel forward --hook abc-123-def --to http://localhost:3000/webhooks/stripe

# 5. When you reconnect after being offline:
hooktunnel forward --hook abc-123-def --to http://localhost:3000/webhooks/stripe
# Prompts: "4 events captured while offline. Replay? [y/N]"

# 6. Replay a specific event by ID:
hooktunnel replay --event evt_1P... --to http://localhost:3000/webhooks/stripe

The team scenario

This is where the ngrok approach breaks down most visibly — four developers mean four different URLs, four entries in Stripe's dashboard, and someone forgetting to update their URL every Monday morning. See HookTunnel's webhook inspection features for the shared-URL team model and the flat $19/month Pro plan for 30-day history and replay.

You have 4 developers. All of them need to test the Stripe webhook integration locally. Each developer runs their own ngrok tunnel on their own machine.

With ngrok free plan:

  • 4 developers = 4 different URLs
  • Each developer needs to be added as a webhook endpoint in Stripe
  • The Stripe account has 4 webhook endpoints configured for local dev
  • Every Monday morning, someone forgets to update their URL, their events stop coming in, they spend 20 minutes debugging before realizing the URL changed

With ngrok paid:

  • 4 developers × $10/month = $40/month for static URLs
  • Still 4 separate webhook endpoints in Stripe
  • Still 4 separate event histories (no one can see what events hit someone else's tunnel)

With HookTunnel:

  • 1 permanent webhook URL configured in Stripe
  • Each developer connects their local tunnel to that same hook
  • Events that arrive when a developer's tunnel is offline are stored and available for replay when they reconnect
  • A developer can replay any event from the last 24 hours (free tier) or 30 days (Pro) against their local handler
  • If a developer wants to reproduce the exact event that caused a bug, they can replay it exactly — same payload, same headers

The practical difference shows up when a real incident happens in test mode. Say a scheduled trial-end event fires on a test customer at 11 PM when no developers are online. With ngrok, the event hits a dead tunnel URL, Stripe retries a few times, and by morning the event is gone. You have to reproduce the test condition manually.

With HookTunnel, the event is stored. Monday morning, you connect, see the event in the history, replay it against your local handler.

Comparison: Stripe CLI vs ngrok vs HookTunnel

| Feature | Stripe CLI | ngrok (free) | ngrok (paid) | HookTunnel | |---|---|---|---|---| | Real events from Stripe account | No | Yes | Yes | Yes | | Permanent URL | N/A | No | Yes | Yes | | Configure Stripe once | N/A | No | Yes | Yes | | Events captured when offline | No | No | No | Yes | | Replay stored events | No | No | No | Yes | | Team-friendly (shared URL) | No | No | No | Yes | | Outcome tracking (Applied state) | No | No | No | Yes | | Works in CI environments | Yes | Partial | Partial | Yes | | Cost for 4-dev team | Free | Free (4 URLs) | $40/month | Varies | | Handler must be running to receive | Yes | Yes | Yes | No | | Supports payload inspection | Limited | Limited | Limited | Yes |

The honest summary: Stripe CLI is the right tool for automated testing and CI. ngrok free gets you through a solo weekend project. ngrok paid solves the URL rotation problem but not the offline capture problem. HookTunnel solves the URL rotation problem, the offline capture problem, and the team coordination problem together. For the full picture of what happens when local webhook testing fails to catch problems before production, see the webhook debugging guide and silent webhook failure patterns.

Your choice depends on where you are in development. For a quick proof of concept, Stripe CLI is fine. For sustained integration work on a team, the permanent URL and offline capture are worth the switch.

The step you'll be most glad you took: configuring your Stripe webhook endpoint once and never touching it again. Every developer on your team connects their local tunnel to the same URL, sees the same event history, and can replay any real event that arrived in the last 30 days. No more Monday morning URL updates.

Stop guessing. Start proving.

Generate a webhook URL in one click. No signup required.

Get started free →