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
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:
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- URL:
https://1a2b3c4d.ngrok-free.app/api/stripe-webhook - Events: select
checkout.session.completed+ others you care about - Add endpoint, copy the signing secret
- Set
STRIPE_WEBHOOK_SECRETin your local.envto that secret - Restart your local dev server to pick up the new env var
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
- Day-to-day dev (you, alone, against test events): Stripe CLI. Lowest setup, instant.
- Sharing with a teammate or testing real Stripe events: ngrok. Real URL, real Stripe webhook secret.
- Permanent staging endpoint at a custom subdomain: Cloudflare Tunnel. More setup, but free + persistent.
- CI / automated testing: mock the events directly in your tests using the JSON fixtures from Stripe's docs. Don't actually hit Stripe in CI.
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):
- 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_SECRETin your production environment to the secret from your production webhook endpoint in the dashboard. - Your production runtime parses the body before sig verification. Common with Vercel + Next.js — see the deep writeup on body-parser issues.
- 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
- [ ] Local dev: Stripe CLI
stripe listen --forward-to ... - [ ] Stable public URL: ngrok (free with rotating URLs, paid for stable)
- [ ] Permanent custom-domain bridge: Cloudflare Tunnel
- [ ] Always do a final $1 live test against the deployed prod endpoint before declaring victory
- [ ] If prod 400s after local works, suspect (1) wrong secret, (2) body parser, (3) stale retries
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 →