How to Receive Stripe Webhooks on Localhost (Three Ways)
Stripe CLI, ngrok, and Hookbase tunnel — the three options for testing Stripe webhooks against your local dev server, with the tradeoffs that matter when you actually use them.
The Setup
You're building a Stripe integration. You need to test how your handler reacts to a real payment_intent.succeeded event. Your dev server is on localhost:4000. Stripe needs a public URL.
Three options. They differ less in capability than in daily friction.
Option 1: Stripe CLI
Stripe ships an official CLI that listens for events on your account and forwards them to your local handler:
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:4000/webhook
The CLI prints a temporary signing secret. Use it as your STRIPE_WEBHOOK_SECRET while developing — it's different from your production webhook secret.
Pros: Official. Free. Triggers test events from the CLI (stripe trigger payment_intent.succeeded). No URL configuration in the Stripe dashboard.
Cons: Only works with Stripe. The signing secret rotates every stripe listen session. Forwards events from your entire account to your laptop, which is fine in test mode and a bit alarming in live mode.
This is the right answer if you only need to test Stripe and don't mind running the CLI alongside your dev server.
Option 2: ngrok
The classic. Works with any provider, not just Stripe:
ngrok http 4000
Take the https://abc123.ngrok-free.app URL it gives you, paste it into the Stripe dashboard webhook config, and you're live.
Pros: Works with every webhook provider, not Stripe-specific.
Cons: Free URLs change every restart. You'll be re-pasting URLs into the Stripe dashboard several times a day. Stable subdomains require a paid plan.
Option 3: Hookbase Tunnel
npm install -g @hookbase/cli
hookbase login
hookbase tunnel --port 4000
You get a stable URL like https://acme.hookbase.app/ingest/stripe-dev. Configure that once in the Stripe dashboard and never touch it again.
Pros: Stable URL. Works with every provider, not just Stripe. Every event is stored, viewable, and replayable from the dashboard. Signature verification happens at the tunnel — your handler doesn't need to verify the Stripe HMAC at all if you don't want to.
Cons: Requires a Hookbase account (free tier covers all dev usage).
Comparison At A Glance
| | Stripe CLI | ngrok (free) | Hookbase |
|---|---|---|---|
| URL changes per session | Pseudo (uses ephemeral secret) | Yes | No |
| Multi-provider | No | Yes | Yes |
| Event history | No | No | Yes |
| Replay past events | Limited (stripe events resend) | No | Yes |
| Signature verification | In your handler | In your handler | At tunnel (optional) |
| Cost | Free | Free / paid for stable URL | Free |
The Stripe-Specific Setup Steps
Whichever tunnel you pick, the dashboard steps are the same:
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- Endpoint URL: your tunnel URL
- Events: pick the ones you care about.
payment_intent.succeeded,charge.refunded,customer.subscription.updatedare the typical starter set. - After saving, click into the webhook and reveal the Signing secret (
whsec_...). This is what your handler uses. - Trigger a test event from the dashboard or with
stripe trigger.
For test mode, use a webhook in your test-mode dashboard and a test-mode signing secret. Live mode webhooks have a separate secret that won't validate test events.
Verifying the Signature
Stripe's signature header is Stripe-Signature and looks like:
t=1700000000,v1=8e3a7f2c...
You parse it, extract the timestamp and v1 hash, then HMAC-SHA256 the string {timestamp}.{raw_body} with your signing secret:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.header('Stripe-Signature'),
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send('Webhook Error');
}
res.send('ok');
}
);
stripe.webhooks.constructEvent does the parsing, hashing, timing-safe comparison, and timestamp freshness check (5-minute tolerance) for you. Don't reimplement it.
Common Issues
"Signature verification failed." Your body is being parsed before reaching the handler. Use express.raw() (or your framework's equivalent) — never express.json() for this route.
"My CLI signing secret stopped working." It rotated. Restart stripe listen and copy the new one.
"Stripe says 'webhook disabled.'" Your endpoint returned non-2xx for too long. Re-enable in the dashboard.
Beyond Local Dev
The Hookbase tunnel URL you used in dev is the same primitive used in production. When you're ready to ship, point production Stripe webhooks at a Hookbase source backed by your prod handler. You get the same event history, replay, and reliability layer in front of your live integration that you used during development.