Splithook

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_...):

  1. Stripe creates a string: timestamp.payload_body (the Unix timestamp, a literal dot, then the raw request body as bytes).
  2. It computes an HMAC-SHA256 of that string using your webhook secret as the key.
  3. 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:

server.js — Stripe signature verification
import Stripe from 'stripe'; import express from 'express'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_... const app = express(); // Critical: raw body, not parsed JSON app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['stripe-signature']; let event; try { event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); } catch (err) { console.error(`Signature verification failed: ${err.message}`); return res.status(400).send(`Webhook Error: ${err.message}`); } // Handle the event switch (event.type) { case 'payment_intent.succeeded': console.log('Payment received:', event.data.object.id); break; default: console.log(`Unhandled event type: ${event.type}`); } res.json({ received: true }); } );

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:

The bypass pattern — don't do this
let event; if (process.env.NODE_ENV === 'development') { // "Temporary" bypass — never gets removed event = JSON.parse(req.body); } else { event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); }

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.

Terminal
# Listen for all events and forward to your local server stripe listen --forward-to localhost:3000/webhooks/stripe # Or trigger a specific test event stripe trigger payment_intent.succeeded

What the CLI does well

Where the CLI falls short

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.

Terminal
# Expose port 3000 to the internet ngrok http 3000 # Output: # Forwarding https://a1b2c3d4.ngrok-free.app -> http://localhost:3000

The workflow

  1. Start ngrok, get a public URL.
  2. Add that URL as a webhook endpoint in the Stripe Dashboard (e.g., https://a1b2c3d4.ngrok-free.app/webhooks/stripe).
  3. Trigger events in test mode — create a payment, update a subscription.
  4. Events arrive at your local server with Stripe's real signature.

What works

Where it breaks

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 format timestamp.body.
  • Sends the event to your destination with the new Stripe-Signature header.

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:

server.js — same handler, valid signatures in dev
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['stripe-signature']; let event; try { // No environment check. Same code in dev and prod. event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); } catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`); } // event.type, event.data.object — all present, all real handleStripeEvent(event); res.json({ received: true }); } );

How to choose

The right method depends on where you are in the integration lifecycle and what your handler actually does.

Do you verify Stripe signatures in your handler? ├── No → Stripe CLI or ngrok — either works, pick whichever is faster for you. └── Yes ├── Do you need to replay past events? │ ├── No → Stripe CLI is the simplest option. Valid signatures, zero setup. │ └── Yes │ ├── Do you need fan-out (1 event → multiple services)? │ │ ├── No → ngrok + Splithook — capture in Splithook, tunnel via ngrok. │ │ └── Yes → Splithook — fan-out with per-destination re-signing. │ └── Do you need to test events from yesterday/last week? │ └── Yes → Splithook — persistent history + re-signed replay.

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?
Yes. Test clocks generate real webhook events in test mode. With Stripe CLI, they're forwarded live. With ngrok, they hit your tunnel URL directly. With Splithook, they're captured and can be replayed later with a fresh signature — which is useful because test clock events often fire in batches and you want to replay individual events to debug specific transitions.
Does Stripe re-send events if my local server is down?
Stripe retries failed deliveries with exponential backoff for up to 3 days. With ngrok, if your tunnel is down, Stripe gets a connection error and retries. With Splithook, events are captured regardless — your provider always gets a 200 OK from Splithook. You replay to localhost when your server is ready, with no dependency on Stripe's retry schedule.
What's the exact tolerance window for Stripe signatures?
The default in Stripe's official libraries is 300 seconds (5 minutes). You can override this by passing a custom tolerance to 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?
If you use the Stripe CLI, yes — the CLI generates its own 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?
Connect webhooks work identically — they're just events for connected accounts. The Stripe CLI supports them with 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