Webhooks are awkward to develop against. The sender lives on the internet, your code lives on localhost:3000, and the interesting bugs (retries, signature failures, out-of-order events) only show up with real traffic.
These eight free tools cover the whole loop: see what's being sent, get it onto your machine, and replay it until your handler is right. Most have paid tiers, but the free tier is enough for development.
Inspect: see the raw request first
Before writing a handler, look at exactly what the sender delivers: headers, body and encoding. It saves you from guessing at the payload shape.
1. webhook.site
Open the site and you get a unique URL. Paste it into the sender's webhook settings and every request shows up live with full headers and body.
Use it when: you're integrating a new provider and want to see real payloads before writing any code.
Watch out: the URLs are public by default. Don't send production data with customer details through it.
2. A local catch-all server (DIY)
Sometimes the best inspector is ten lines you control:
// catch.mjs: log everything, return 200 import http from "node:http"; http.createServer((req, res) => { let body = ""; req.on("data", c => (body += c)); req.on("end", () => { console.log(new Date().toISOString(), req.method, req.url); console.log(req.headers); console.log(body, "\n---"); res.end("ok"); }); }).listen(4000, () => console.log("listening on :4000")); Use it when: data must stay on your machine. Pair it with a tunnel (below).
Tunnel: get the internet to your laptop
3. Cloudflare Tunnel (cloudflared)
cloudflared tunnel --url http://localhost:3000 That gives you a temporary trycloudflare.com URL with no account needed for quick tunnels. With an account, you can set up a stable hostname on your own domain.
Use it when: you want a free, reliable tunnel, especially a stable URL you don't have to re-paste into provider dashboards every session.
4. ngrok
ngrok http 3000 The best-known option, with a local web inspector at http://127.0.0.1:4040 that shows every request and lets you replay it with one click. That replay button alone is worth a lot during debugging.
Use it when: you want inspection and replay built into the tunnel.
Watch out: free-tier limits and domain options change over time, so check the current plan.
5. localtunnel
npx localtunnel --port 3000 Open source, no signup, one command.
Use it when: you need a quick tunnel and don't want an account.
Watch out: reliability varies. Fine for a quick test, frustrating for a long session.
6. smee.io
Made for GitHub-style webhook development. The sender posts to a smee channel, and a small client forwards to your local server:
npx smee-client --url https://smee.io/YOUR_CHANNEL --target http://localhost:3000/webhook Use it when: you're building a GitHub App or similar, and like that events are buffered in the channel and visible in a browser.
Replay and simulate
7. Provider CLIs (Stripe CLI and friends)
Several providers ship CLIs that forward events to localhost and trigger test events on demand. The Stripe CLI is the best-known example:
stripe listen --forward-to localhost:3000/webhooks/stripe stripe trigger payment_intent.succeeded It also prints the webhook signing secret for the session, so you can test signature verification properly instead of disabling it "just for dev".
Use it when: your provider has one. It's the most realistic test you'll get, with real event shapes and real signatures.
8. curl and saved payloads
The humblest tool, and the one you'll use most. Save real payloads (from webhook.site or your inspector) as fixtures, then replay them:
curl -X POST http://localhost:3000/webhook \ -H "Content-Type: application/json" \ -H "X-Event-Id: evt_test_123" \ --data @fixtures/order_created.json Send the same request twice to test idempotency. Send events out of order to test your state handling. Commit the fixtures, because they make excellent test data.
A workflow that works
- Inspect with webhook.site (or your catch-all) to capture real payloads.
- Save them as fixtures in the repo.
- Build the handler against the fixtures with curl.
- Tunnel (cloudflared or ngrok) for an end-to-end test with real signatures.
- Replay duplicates and failures until the handler survives them.
Make your own handler misbehave on purpose
The tools above get real events onto your laptop. The bugs that hurt in production show up when your handler is slow or failing, because that's when providers retry. You can trigger that behavior deliberately with a small wrapper in front of your real handler:
// chaos.mjs: wrap a handler with failure modes controlled by env vars export function chaos(handler) { let calls = 0; return async (req, res) => { calls++; const failFirst = Number(process.env.FAIL_FIRST || 0); // e.g. 2 const delayMs = Number(process.env.DELAY_MS || 0); // e.g. 15000 if (calls <= failFirst) { res.statusCode = 500; return res.end("forced failure"); } if (delayMs) await new Promise(r => setTimeout(r, delayMs)); return handler(req, res); }; } // usage: http.createServer(chaos(myHandler)).listen(3000) Then run three experiments through a tunnel with a real provider, or with replayed fixtures:
-
FAIL_FIRST=2: the provider should retry. Check your logs: how long between attempts, and does each retry carry the same event ID? That ID is what your deduplication has to key on. -
DELAY_MSabove the provider's timeout: many providers give up after somewhere between 5 and 30 seconds and count the delivery as failed, even though your handler finished the work. You'll see the same event processed twice. That's the practical argument for acknowledging fast and doing the real work in a background job. - Kill the server mid-request: stop the process while a delayed request is in flight, then restart it. Whatever the handler did before the crash has to be safe to repeat when the retry arrives.
Write down what you observe per provider. Retry schedules and timeouts differ, and the docs aren't always precise, so ten minutes of testing beats guessing.
What none of these test
All of these help you receive webhooks. They don't help with the other half: making sure an event that should be sent at a given time, or retried after a failure, actually is. If your app sends scheduled or delayed callbacks, you need that side to be durable too, whether that's your own queue or a hosted scheduler like delay.run that makes the HTTP call at the right time and retries on failure. Tunnels are great for testing either side against localhost.
Related reading
- How late is 'every minute'? An experiment you can run on cron, systemd timers and sleep loops
- How to verify HMAC-SHA256 signed webhooks in Node and Python
- Return 200 fast: why your webhook handler should do almost nothing
- Make scheduled job handlers idempotent: safe to run twice
Which one is in your toolbox? And is there a webhook debugging tool I've missed that you'd put above all of these?



