Guide
How to test Stripe webhooks locally — with valid signatures
Updated May 2026 · 12 min read
How many hours have you spent debugging a Stripe webhook handler that works fine locally but rejects every event in production? If the answer is "too many," you're in the majority. Stripe's own docs note that signature verification is the single most common source of webhook integration failures.
The root cause is almost always the same: your local development environment can't receive real Stripe events with valid signatures, so you skip verification during development. That skip creates a blind spot. Code that touches the raw request body, parses headers, or handles edge cases around content types is never tested against a real HMAC — until it hits production on a Friday evening.
This guide covers three methods to test Stripe webhooks on your local machine, from the simplest to the most production-faithful. Each method has clear trade-offs. By the end, you'll know which one fits your workflow — and why keeping signature verification enabled in dev matters more than most teams realize.
Why local Stripe webhook testing is hard
Testing a REST API locally is straightforward: you control the client and the server, and both run on your machine. Webhooks invert that relationship. The client is Stripe's infrastructure, and it needs to reach your server over the public internet. That inversion creates three technical problems.
1. Your localhost isn't reachable from Stripe's servers
Stripe sends webhook events via HTTP POST to a URL you configure in the Dashboard. That URL must be publicly accessible. Your development machine, sitting behind NAT and a firewall, doesn't have a public IP. You need a tunnel (ngrok, cloudflared, Tailscale Funnel) or a proxy that accepts events on your behalf and forwards them to localhost.
2. Stripe signs every event — and signatures expire
Every webhook Stripe sends includes a Stripe-Signature header containing a timestamp and an HMAC-SHA256 digest. Stripe's official libraries reject any event where the timestamp is older than 5 minutes (the default tolerance). If you capture an event and try to replay it 10 minutes later, the signature is technically valid but the timestamp is stale — your handler rejects it.
3. You can't trigger real events on demand
Some Stripe events only fire under specific conditions: invoice.payment_failed requires a card that actually declines, customer.subscription.updated requires a subscription to exist. In test mode, you can trigger events via the Dashboard or CLI, but these are synthetic — they don't carry the same payload structure as events from live integrations. The delta between synthetic and real events is where edge-case bugs hide.
Common mistake #1
Using stripe trigger events as your only test input. CLI-triggered events use fixed fixture payloads that don't reflect your account's actual data — custom metadata fields, specific price IDs, and connected account structures are all missing. Supplement with real captured events.
The signature verification trap
Before comparing tools, you need to understand what's actually in a Stripe-Signature header and why skipping verification in dev is a specific, measurable risk — not just "bad practice."
How Stripe's HMAC v1 scheme works
When Stripe sends an event, it computes a signature using your endpoint's signing secret (whsec_...):
- Stripe creates a string:
timestamp.payload_body(the Unix timestamp, a literal dot, then the raw request body as bytes). - It computes an HMAC-SHA256 of that string using your webhook secret as the key.
- It sends the result in the header as
t=timestamp,v1=hex_digest.
Your handler must reconstruct the same string, compute the same HMAC, and compare. Here's the official pattern using the Stripe Node.js SDK:
The call to constructEvent() does three things: it parses the Stripe-Signature header, recomputes the HMAC over the raw body using your secret, and checks that the timestamp is within the 5-minute tolerance window.
The 5-minute tolerance window
Even if the HMAC matches, Stripe's library rejects events where t= is more than 300 seconds old. This prevents replay attacks in production. But in development, it means any event you captured more than 5 minutes ago is dead — you can't replay it without getting Timestamp outside the tolerance zone.
Why the dev bypass is dangerous
The most common workaround is wrapping the verification in an environment check:
This creates two code paths: one that runs in dev, one that runs in production. Any bug in how the body is parsed, how headers are passed, or how the raw bytes are preserved — you'll only discover it in production. The JSON.parse branch doesn't care if req.body is a Buffer or a string; constructEvent does.
Common mistake #2
Using express.json() instead of express.raw() for the webhook route. The JSON middleware parses the body into an object, but constructEvent needs the raw bytes to recompute the HMAC. The signature will never match against JSON.stringify() output because whitespace and key ordering differ. This bug only surfaces when verification is enabled — which is why the dev bypass hides it.
Method 1: Stripe CLI
The Stripe CLI is Stripe's official tool for local webhook development. It creates a WebSocket connection to Stripe's servers and forwards events to a local URL.
What the CLI does well
- Zero network config. No tunnels, no public URLs. The CLI maintains an outbound WebSocket — your firewall doesn't need holes.
- Valid signatures out of the box. The CLI generates a local signing secret (
whsec_...) and signs forwarded events with it. Your handler'sconstructEventcall works normally. - Event filtering. You can listen for specific event types:
stripe listen --events payment_intent.succeeded,invoice.paid.
Where the CLI falls short
- Events are synthetic or session-scoped.
stripe triggersends fixture payloads, not real events from your account. Live events from your Dashboard are forwarded, but only while the CLI session is running. - No replay. If an event arrived yesterday and you need to reproduce the bug, you can't replay it through the CLI. You'd need to trigger a new event manually.
- No fan-out. The CLI forwards to one URL. If your payment service, notification service, and analytics pipeline all need the same event, you need three CLI sessions or a manual proxy.
- No persistent history. Once you close the terminal, the events are gone. There's no way to search, filter, or inspect past events.
Common mistake #3
Using the CLI's whsec_ secret in production. The signing secret printed by stripe listen is local-only. Your production webhook endpoint has a different secret, visible in the Stripe Dashboard under Developers → Webhooks → Signing secret. Mixing them up means verification silently fails in one environment.
Method 2: ngrok + manual replay
ngrok exposes your localhost on a public HTTPS URL. You configure that URL as a webhook endpoint in the Stripe Dashboard, and real events from your test-mode account flow directly to your local server.
The workflow
- Start ngrok, get a public URL.
- Add that URL as a webhook endpoint in the Stripe Dashboard (e.g.,
https://a1b2c3d4.ngrok-free.app/webhooks/stripe). - Trigger events in test mode — create a payment, update a subscription.
- Events arrive at your local server with Stripe's real signature.
What works
- Real events, real signatures. Stripe signs the event with your endpoint's actual secret.
constructEventworks normally — no special dev secret needed. - Any HTTP traffic. Not limited to Stripe — GitHub, Shopify, Twilio all work through the same tunnel.
- Web inspector. ngrok's local dashboard (
localhost:4040) shows request/response pairs in real time.
Where it breaks
- Signatures expire on replay. ngrok's inspector has a "Replay" button, but it sends the original request with the original timestamp. If more than 5 minutes have passed,
constructEventrejects it. This is the most common frustration with the ngrok webhook workflow. - URL changes on restart. Free ngrok URLs are ephemeral. Every time you restart the tunnel, you need to update the webhook endpoint in Stripe's Dashboard. Paid ngrok plans offer static domains.
- No history across sessions. Close the tunnel, lose the inspector data.
- Single destination. One ngrok URL maps to one local port. Fan-out requires manual setup.
Common mistake #4 Leaving a test ngrok URL configured in the Stripe Dashboard after your dev session. Stripe will retry failed deliveries for hours. When you restart ngrok with a new URL, the old endpoint accumulates failures and Stripe may disable it automatically. Always delete test endpoints when you're done, or use a permanent capture URL that doesn't change between sessions.
Method 3: Splithook — capture, re-sign, replay
Splithook is a webhook proxy that captures events from any provider, stores them persistently, and re-signs replays with a fresh HMAC computed using your real signing secret. The signature timestamp is rewritten to the current time, so Stripe's 5-minute tolerance window is never an issue.
The difference from the previous two methods: Splithook separates capture from consumption. Events are captured once at a stable URL and can be replayed to any destination at any time — with a valid signature every time.
Configure Splithook as your Stripe webhook URL
In Stripe Dashboard → Developers → Webhooks, add your Splithook endpoint URL (e.g., https://splithook.com/e/your-endpoint). This URL never changes — you configure it once.
Store your Stripe signing secret
Add your endpoint's whsec_... secret in the Splithook dashboard. It's encrypted at rest with libsodium and used exclusively for re-computing signatures at replay time. Splithook selects the correct signing algorithm (Stripe v1, GitHub sha256, Shopify base64) based on the detected provider.
Add a destination (your localhost)
Point a destination at your local dev server — either directly via a tunnel URL (https://abc.ngrok-free.app/webhooks/stripe) or using Splithook's built-in tunnel. You can add multiple destinations: one for your payment service on port 3000, one for your notification service on port 8080.
Replay with valid signatures
Every captured event is stored with its original body and headers. When you click replay, Splithook:
- Takes the original raw body bytes (preserved exactly as Stripe sent them).
- Generates a new timestamp (
t=now). - Recomputes the HMAC-SHA256 using your
whsec_secret and the formattimestamp.body. - Sends the event to your destination with the new
Stripe-Signatureheader.
Your handler's constructEvent call passes — same code, same secret, same verification logic as production. No bypass, no conditional branch.
Here's what the handler looks like — identical to the production code shown earlier, because that's the point:
How to choose
The right method depends on where you are in the integration lifecycle and what your handler actually does.
Most developers start with the Stripe CLI, move to ngrok when they need real account data, and add Splithook when they hit the signature-expiration wall or need fan-out. The three tools are complementary, not competing.
Common mistake #5
Committing an if (NODE_ENV === 'development') skip_verification block and assuming you'll remove it later. In a team of five, that block survives every code review because everyone recognizes it as "the way we test webhooks." Six months later, a refactor changes the body parser and breaks the raw-bytes assumption. Production gets SignatureVerificationError. The fix is to never create the bypass in the first place.
Frequently asked questions
Can I use Stripe's test clock webhooks with these methods?
Does Stripe re-send events if my local server is down?
What's the exact tolerance window for Stripe signatures?
constructEvent — but setting it to a very large value defeats the purpose of replay protection. In production, keep the default. In dev, use a tool that re-signs with a current timestamp instead of widening the window.
Do I need a different signing secret for dev and prod?
whsec_ secret that's different from your Dashboard endpoint's secret. If you use ngrok or Splithook with a real Stripe webhook endpoint, you use the same secret as production (from your test-mode endpoint). Using the same secret in dev means your verification logic is identical across environments.
Can I test webhooks from Stripe Connect with these methods?
stripe listen --connect. ngrok and Splithook receive them like any other webhook. The signing secret is the same one from your platform's webhook endpoint configuration.
Test your Stripe handler with real signatures.
Capture a real Stripe event, replay it to localhost with a valid HMAC — no bypass, no expired timestamps. 10 re-signed replays on the free plan.
Start free — keep verification on