Testing Webhooks Locally: Tunnels, Replay and Fixtures
How to test webhooks locally without the usual signature bugs: tunnels vs provider CLIs, replay loops, raw-byte fixtures, retries and idempotency, from real
You paste the tunnel URL into the Stripe dashboard, click “Send test webhook”, and the dashboard says 200. Then you deploy, and the same handler throws No signatures found matching the expected signature for payload on every real event. Nothing about your code changed. What changed is that a body parser got in front of the route and your production framework re-serialized the JSON before the HMAC ran.
That bug costs most teams an afternoon. It is also the clearest sign that the hard part of webhook testing was never the tunnel. Getting a public URL pointed at port 3000 is a solved problem with four good options. Everything after that (signatures, raw bytes, retries, ordering, idempotency, replay) is where the time actually goes.
This is how we test webhooks locally on client projects: what we tunnel, what we skip tunnelling entirely, how we capture fixtures, and which failures a tunnel will happily hide from you until launch week.
- The tunnel is maybe 10 percent of the work. Signature verification against the exact request bytes, retry handling and idempotency are the parts that break in production.
- If the provider ships a CLI (
stripe listen,gh webhook forward,shopify app dev), use it instead of a tunnel: no public URL, no dashboard reconfiguration, and you can fabricate events on demand. - Store fixtures as raw body bytes plus the full header set. Never store a parsed object: re-serializing JSON changes key order and whitespace, which invalidates every HMAC.
- Re-sign fixtures with a fresh timestamp when you replay them. Stripe’s default tolerance window is 300 seconds, so a capture from yesterday gets rejected even though your code is correct.
- Response deadlines are tight (Slack around 3 seconds, Shopify 5, GitHub 10). Persist the payload, return 200, process asynchronously, and dedupe on the provider’s event ID.
The tunnel is the smallest part of the problem
A tunnel answers one question: how does a server on the public internet reach a process bound to 127.0.0.1? That is it. It tells you nothing about whether your handler is correct, idempotent, or fast enough.
Here is a list of things we have actually shipped broken despite a green tunnel test: a handler that parsed JSON before verifying the signature, a handler that returned 500 on duplicate events and triggered three days of Stripe retries, a handler that took 8 seconds because it called an third-party API inline, and a handler that processed subscription.updated before subscription.created because the provider makes no ordering guarantee and our queue had two workers.
Not one of those is a connectivity bug. Keep that in mind while choosing a tunnel, because the choice matters less than you think.

Picking a tunnel: ngrok, cloudflared, Tailscale
Three options cover almost every case.
# Cloudflare quick tunnel: no account, random *.trycloudflare.com hostname
cloudflared tunnel --url http://localhost:3000
ngrok with the free static domain, so the URL survives a restart
ngrok http 3000 --url=yourname.ngrok-free.app
Tailscale Funnel: stable *.ts.net hostname, HTTPS terminated for you
tailscale funnel 3000
The reason an ngrok webhook setup is still the default for most people is the local inspector at http://127.0.0.1:4040. It records every request with full headers and body, and it has a Replay button. That single button removes the worst part of the dev loop: you no longer need the provider to resend anything. Capture one real event, then hammer your handler with it twenty times while you fix the parsing. ngrok’s free tier gives you one reserved domain and one online agent, which is enough for solo work and not enough for a team sharing an account.
Cloudflare’s quick tunnel wins when you want zero signup and don’t care that the hostname rotates every restart. If the provider’s endpoint URL lives in a dashboard you have to edit by hand, a rotating hostname is genuinely painful, so register a named tunnel against a domain you control instead. Tailscale Funnel is the right answer when the thing calling you is internal (another service on the tailnet, a staging worker) and you’d rather not publish anything.
One warning that bites people on WordPress and Laravel projects: a tunnel puts your dev machine on the public internet at a guessable-ish hostname. Scanners find trycloudflare.com hosts within minutes. If your local site has WP_DEBUG on, Debug Bar installed, or an admin account with password admin, you just published that. Bind your dev server to localhost only, let the tunnel be the single ingress, and kill the tunnel when you walk away.
Skip the tunnel when the provider ships a CLI
Several major providers will push events to your machine over an outbound connection they initiate. No public URL, no firewall holes, no dashboard edits. This is the option the listicles bury.
# Stripe: outbound websocket, forwards to any local path
stripe listen \
--forward-to localhost:3000/webhooks/stripe \
--events checkout.session.completed,charge.refunded,customer.subscription.updated
In a second terminal, fabricate an event with realistic object shape
stripe trigger checkout.session.completed
GitHub, via the official extension: gh extension install cli/gh-webhook
gh webhook forward --repo=acme/site --events=push,pull_request \
--url=http://localhost:3000/webhooks/github
Two details people trip on. stripe listen prints its own whsec_ signing secret on startup, and it is not the secret from your dashboard endpoint. Put it in .env.local or every verification fails. And stripe trigger builds a real object in test mode, so the payload has correct types and nested shapes, unlike the hand-written JSON you were about to write.
Shopify’s shopify app dev provisions a Cloudflare tunnel and rewrites your app URLs automatically. Slack’s Socket Mode removes the public endpoint requirement for apps entirely. Twilio’s CLI does the same thing for phone number webhooks. Check for a CLI before you reach for ngrok: the CLI path is faster to start and leaves no public surface behind.
Two kinds of replay, and why local replay wins
Provider-side replay is the Redeliver button in GitHub’s “Recent Deliveries” tab or Resend in the Stripe dashboard. It’s authentic, it’s slow, and it’s rate limited. Use it to confirm a fix against a specific historical event, especially the one that broke at 3am.
Local replay is you firing stored bytes at your own handler. It’s instant, it works offline, and it runs in CI. This is the loop you want to live in. The ngrok inspector gives you a one-click version. For everything else, write it yourself in about fifteen lines.
If you sit between providers and want managed replay with retry inspection across environments, Hookdeck and Svix Play both do this well, and both cost less than the hours you’d spend rebuilding delivery logs. For a small team, a folder of JSON files and a script is enough.
Fixtures: capture the bytes, not the object
Here is the rule that fixes the signature bug from the opening paragraph. An HMAC is computed over the exact bytes the provider sent. Capture req.body after a JSON parser has run, then JSON.stringify it back, and you have changed whitespace and possibly key order. The signature will never match again, and you will blame your crypto code.
Capture the raw buffer:
import express from 'express';
import { writeFileSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
mkdirSync('fixtures/webhooks', { recursive: true });
const app = express();
// express.raw leaves req.body as a Buffer: the exact bytes the signature covers
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
if (process.env.CAPTURE_WEBHOOKS === '1') {
const file = fixtures/webhooks/${Date.now()}-${randomUUID().slice(0, 8)}.json;
writeFileSync(file, JSON.stringify({
headers: req.headers, // keep every header, you will need the signature one
body: req.body.toString('utf8') // store as a string, never as a parsed object
}, null, 2));
}
// ... verify, then handle
res.sendStatus(200);
});
Now replay. The catch: Stripe rejects anything outside a 300 second tolerance window by default, so a captured stripe-signature header expires five minutes after you record it. Re-sign at replay time with a fresh timestamp.
// node scripts/replay.js fixtures/webhooks/1739... .json
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
const fixture = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const secret = process.env.STRIPEWEBHOOKSECRET;
const t = Math.floor(Date.now() / 1000);
const signed = ${t}.${fixture.body}; // Stripe signs timestamp + "." + raw body
const v1 = createHmac('sha256', secret).update(signed).digest('hex');
const res = await fetch('http://localhost:3000/webhooks/stripe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'stripe-signature': t=${t},v1=${v1}
},
body: fixture.body // byte-for-byte, no re-stringify
});
console.log(res.status, await res.text());
In unit tests, don’t hand-roll the header. The Stripe Node SDK exposes stripe.webhooks.generateTestHeaderString({ payload, secret }) for exactly this, and the equivalent exists in the Python, Ruby and PHP libraries. For GitHub it’s a plain sha256= HMAC of the body with your secret, compared using crypto.timingSafeEqual. Shopify base64-encodes the digest instead of hex. Three providers, three encodings: reason enough to keep one fixture per provider and a test that asserts verification fails on a tampered body.
Keep fixtures small and named by event type: charge.refunded.json, push.tag-delete.json. Redact live customer data when you commit them. We keep a fixtures/webhooks/README.md noting which captures came from real traffic and which came from stripe trigger, because the real ones occasionally contain fields the test fabricator omits.
The failure modes a tunnel will never show you
Your tunnel delivers one event, once, in order, with no deadline pressure. Production does none of those things.
Retries and duplicates
Stripe retries failed deliveries with exponential backoff for up to three days in live mode. Shopify retries 19 times across roughly 48 hours. GitHub does not automatically retry at all, which surprises people: a 500 there means the event is gone unless you redeliver by hand. Duplicates arrive even on success, because a network timeout after your 200 looks identical to a failure from the sender’s side. Store event.id with a unique index and return 200 on a duplicate rather than 409.
Response deadlines
Slack expects an acknowledgement in about 3 seconds, Shopify in 5, GitHub in 10. Any handler that calls an third-party API inline will eventually blow through these under load. The pattern that works: write the raw payload to a table or queue, return 200, process in a worker. Test the slow path by adding an artificial await sleep(6000) in dev and watching what the provider does. Most people have never seen their own retry storm.
Ordering
No mainstream provider guarantees order. Build handlers that tolerate updated arriving before created, usually by fetching current state from the provider’s API with the ID in the payload rather than trusting the payload body as the source of truth. For money-related objects this is not optional.
Secrets rotation
Accept two valid signing secrets during a rotation window. One line of code, saves an outage. Same pattern we use when routing form submissions to Slack, email and a CRM, where each downstream has its own secret with its own rotation schedule.
A layout for testing webhooks locally that holds up
Three layers, and each one earns its place.
- Fixture tests in CI. Fast, hermetic, no network. Every event type you handle gets one fixture and one test asserting the side effect. These run in under a second and catch 80 percent of regressions.
- Local replay during development. Provider CLI where it exists, tunnel plus the ngrok inspector where it doesn’t. This is your edit-and-see loop.
- One real endpoint on staging. Registered with the provider, pointed at a deployed environment, used to verify the handshake, TLS, signature secret and timeout behaviour under real conditions before launch. This is the only layer that proves infrastructure works.
Skipping layer 3 is the most common mistake. A tunnel bypasses your load balancer, your WAF and whatever proxy strips or rewrites headers. We have watched a Cloudflare rule happily eat a webhook with a POST body larger than expected while every local test passed.
For static and JAMstack projects where you’re receiving form data rather than provider events, the same discipline applies in reverse: you’re the sender’s destination. Services like WebForms post submissions to your webhook URL, so test that endpoint with the same captured-fixture approach rather than clicking submit on a live form twenty times. If the destination is doing anything with files, our notes on direct-to-storage upload patterns cover the payload shapes you’ll see.
Frequently Asked Questions
Do I still need ngrok in 2026?
Not always. If your provider has a CLI with event forwarding (Stripe, GitHub, Shopify, Twilio, Slack Socket Mode), that path is faster and exposes nothing publicly. Reach for ngrok, cloudflared or Tailscale Funnel when the provider only supports a configured HTTPS URL, which still covers most smaller SaaS platforms.
Why does my signature verification fail locally but pass on the provider’s test button?
Almost always because something parsed the body before you verified it. JSON parsing then re-serializing changes the bytes, and the HMAC covers bytes. Use express.raw(), Laravel’s $request->getContent(), or Django’s request.body before any middleware touches it, and verify first, parse second.
Can I run webhook tests in CI without a tunnel?
Yes, and you should. Store captured payloads as fixtures, generate a valid signature at test time with the provider SDK’s test helper, and post directly to your handler in-process. No network, no flakiness, runs in milliseconds. Keep tunnels for interactive development only.
How do I test retry and duplicate handling?
Replay the same fixture twice in a single test and assert the side effect happened once. For retries, make your handler return 500 behind a feature flag, trigger a real event on staging, and watch the provider’s delivery log fill with attempts. Seeing the backoff pattern once is worth more than reading about it.
Is it safe to leave a tunnel running overnight?
No. Scanners find public tunnel hostnames quickly, and a dev environment typically has debug output, seeded admin accounts and verbose error pages. Start the tunnel when you need it and stop it when you’re done. If you need a long-lived endpoint, deploy a staging environment instead.
If you’re starting from nothing today, do this in order: run the provider CLI if one exists, capture three real payloads to disk, write one fixture test per event type, then register a staging endpoint before you ship. The tunnel is a convenience you’ll stop thinking about within a week. The fixtures are the thing you’ll still be running in CI two years from now.


