Testing Stripe Webhooks Locally: Stripe CLI vs ngrok vs Cloudflare Tunnel

You can't test Stripe webhooks against localhost directly — Stripe needs a public URL to deliver events to. Three options bridge that gap. Each works; each has a different gotcha. Below: the setup, the trade-offs, and which approach fits which workflow.

The TL;DR

Tool Best for Cost Gotcha
Stripe CLI Day-to-day dev iteration Free Forwards events from a separate test webhook secret — won't catch prod-secret bugs
ngrok Sharing a real URL with teammates / external services $8-20/mo for stable URLs URL changes on every restart unless you're on the paid tier; rate limits on free tier
Cloudflare Tunnel (`cloudflared`) Permanent local-public bridge tied to your domain Free with a Cloudflare account More setup; tunnel runs as a daemon you have to keep alive

Option 1 — Stripe CLI (default for local dev)

Install:

# macOS
brew install stripe/stripe-cli/stripe

# Linux
curl -L https://github.com/stripe/stripe-cli/releases/latest/download/stripe_X.X.X_linux_x86_64.tar.gz | tar xz

# Windows: download from github.com/stripe/stripe-cli/releases

Authenticate:

stripe login
# Opens a browser; click Allow Access

Forward events to your local dev server:

stripe listen --forward-to localhost:3000/api/stripe-webhook

Output:

> Ready! Your webhook signing secret is whsec_xyz123...
>    (^C to quit)

The whsec_*** that the CLI prints is a SEPARATE secret used only for this listen session. Put it in your local .env file as STRIPE_WEBHOOK_SECRET — your code verifies signatures against this secret, not your production secret.

Trigger test events:

# In another terminal, while stripe listen is running:
stripe trigger checkout.session.completed
stripe trigger payment_intent.succeeded
stripe trigger charge.refunded
Critical gotcha: the secret printed by stripe listen is NOT the same as your production endpoint's signing secret. If your local code works but production fails sig verification, the CLI didn't catch it because the CLI's test secret is verified-against, not your real prod secret. Always do a final live $1 test against your deployed endpoint before declaring victory.

Option 2 — ngrok (when you need a stable public URL)

Install:

brew install ngrok/ngrok/ngrok
# Or download from ngrok.com/download

Authenticate (free account):

ngrok config add-authtoken YOUR_TOKEN_FROM_NGROK_DASHBOARD

Start tunnel:

ngrok http 3000

Output:

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

Configure that https://1a2b3c4d.ngrok-free.app URL as a Stripe webhook endpoint:

  1. Stripe Dashboard → Developers → Webhooks → Add endpoint
  2. URL: https://1a2b3c4d.ngrok-free.app/api/stripe-webhook
  3. Events: select checkout.session.completed + others you care about
  4. Add endpoint, copy the signing secret
  5. Set STRIPE_WEBHOOK_SECRET in your local .env to that secret
  6. Restart your local dev server to pick up the new env var
Why ngrok over Stripe CLI: the URL is real and addressable from anywhere. Useful when (a) you want a teammate to hit your endpoint from their browser, (b) you're testing third-party integrations that aren't Stripe, or (c) you want to verify your code against a real Stripe webhook secret (not the CLI's test secret).

Free tier ngrok URLs change every restart. Paid tier ($8-20/mo) gives you a stable subdomain like https://your-handle.ngrok.io.

Option 3 — Cloudflare Tunnel (for permanent custom-domain bridge)

If you already have a domain on Cloudflare DNS, you can run a permanent tunnel that bridges https://dev.yourdomain.com to your local dev server. Free.

Install:

brew install cloudflare/cloudflare/cloudflared
# Or apt-get / snap on Linux

Authenticate:

cloudflared tunnel login
# Opens browser; pick the domain you want to bridge

Create + configure tunnel:

cloudflared tunnel create dev-stripe
# Outputs a tunnel UUID

# Create config at ~/.cloudflared/config.yml:
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: <your-tunnel-uuid>
credentials-file: /Users/you/.cloudflared/<your-tunnel-uuid>.json

ingress:
  - hostname: dev.yourdomain.com
    service: http://localhost:3000
  - service: http_status:404
EOF

# Add DNS route:
cloudflared tunnel route dns dev-stripe dev.yourdomain.com

# Start the tunnel:
cloudflared tunnel run dev-stripe

Now https://dev.yourdomain.com/api/stripe-webhook hits your local dev server. Configure that as the Stripe webhook endpoint in the dashboard.

Run as a service so you don't have to re-start the daemon every reboot:

sudo cloudflared service install

Which to use when

The "test events look right but production fails" trap

Common scenario: you set up Stripe CLI locally, every test event passes, you deploy, every event 400s.

Causes (in order of likelihood):

  1. You used the CLI's test secret in production. Production Stripe events are signed with the production endpoint secret, not the CLI's session secret. Set STRIPE_WEBHOOK_SECRET in your production environment to the secret from your production webhook endpoint in the dashboard.
  2. Your production runtime parses the body before sig verification. Common with Vercel + Next.js — see the deep writeup on body-parser issues.
  3. The events you're getting are stale retries from before your last fix. See the retry-behavior post; the noise drains in 72 hours.

Diagnostic tool that helps catch these in seconds: /webhook/diagnostic/ — paste your live sig + body + secret, get a verdict.

Summary

Done with the local-test loop and want it shipped to production?

$199 flat (first 3 clients, then $399). Local-tested + production-verified before delivery, $1 live test refunded after.

See the offer →

Related