How to Test Webhooks Locally
Webhooks are server-to-server. The provider POSTs to a public HTTPS URL — but the code you're trying to debug runs on localhost:3000, which the provider can't reach. That mismatch is the whole problem with testing webhooks locally.
This guide shows the fastest way to bridge it, works for any provider (Stripe, GitHub, Shopify, Razorpay, Twilio, Slack, SendGrid, or your own internal service), and — crucially — lets you replay captured events so you're not making real payments just to test a handler.
The pain
You're building a handler for, say, payment_intent.succeeded. To test it you need a real event to actually arrive at your code. But:
- Your laptop has no public URL.
- The provider's "send test event" button (if it exists) sends a canned payload, not the real shape your account produces.
- Triggering a real event means making a real $1 charge, opening a real PR, or shipping a real order — every single iteration.
- When your handler throws, you have to manufacture another real event to try again.
You end up debugging by archaeology: scrolling logs, re-reading docs, and polluting your real Stripe/Shopify account with test junk.
The painful manual way
The classic approach is a generic tunnel plus print-debugging:
# expose localhost to the internet
ngrok http 3000
# -> https://a1b2c3.ngrok-free.app (changes every restart)
Then you paste that URL into the provider, trigger a real event, and console.log your way through. The problems:
- The URL changes every time the tunnel restarts — re-paste it into every provider dashboard, again.
- No history. Once an event flies by, it's gone. Want to test the same payload again? Make another real event.
- No replay, no inspection. You can't see the exact bytes the provider sent, and you can't re-send them.
- The tunnel is a generic pipe — it knows nothing about webhooks, signatures, or providers.
The Anonymily way
Anonymily is a webhook-native tool: a persistent capture URL, full request history, one-command forwarding to localhost, and replay. One command:
npx @anonymilyhq/cli listen 3000
That prints a capture URL like https://hook.anonymily.com/ab12cd34 and streams every webhook sent to it straight to http://localhost:3000, showing you the full headers and body as they arrive:
🆓 FREE | 0/200 | 48h
Listening on hook.anonymily.com/ab12cd34 → localhost:3000
⇢ POST payment_intent.succeeded 200 OK 42ms
stripe-signature: t=1719500000,v1=5257a8...
{ "id": "evt_1Nyz...", "type": "payment_intent.succeeded", ... }
1. Point the provider at your capture URL
Paste the Anonymily URL into the provider's webhook settings (Stripe → Developers → Webhooks, GitHub → Settings → Webhooks, etc.). Because the URL is persistent, you do this once — it survives restarts, reboots, and tomorrow morning.
2. Trigger an event — or synthesize one
Make a real event if you want. But you don't have to: Anonymily can fire a realistic synthetic event for you, correctly signed, so you never touch your real account:
# list the available provider/event combos
npx @anonymilyhq/cli trigger --list
# fire a synthetic Stripe payment into your hook
npx @anonymilyhq/cli trigger stripe payment_intent.succeeded --hook <hookId> --token <PAT>
3. Replay instead of re-triggering
This is the part that makes local testing pleasant. When your handler throws, fix the code and replay the exact same captured request — no new real event needed:
npx @anonymilyhq/cli replay <hookId> <requestId>
Same bytes, same headers, same signature, as many times as you like. Tighten the loop until it's green.
4. When you can't tell why it failed, ask the AI
If a webhook 500s and the payload is cryptic, Anonymily's AI reads the request, your handler's response, and the signature, and tells you why it failed — then drafts the corrected handler. Nobody else does this across every provider.
A handler to test against
import express from "express";
const app = express();
app.post("/", express.json(), (req, res) => {
console.log("received:", req.body.type);
// ... your logic ...
res.sendStatus(200);
});
app.listen(3000, () => console.log("listening on :3000"));
Run it, run npx @anonymilyhq/cli listen 3000 in another terminal, and trigger or replay events at it.
Tips for reliable local testing
- Return
2xxfast. Providers time out in seconds; do heavy work in a background job. - Test the failure paths. Replay the same event after introducing a bug to confirm your error handling and retries behave.
- Verify signatures even locally. It catches the raw-body bug early — see How to verify webhook signatures (HMAC).
- Keep one persistent URL per provider so you never re-paste config.
Next steps
- How to test Stripe webhooks on localhost — the same flow, end-to-end for Stripe.
- Anonymily vs ngrok — why a webhook-native tool beats a raw tunnel here.
- The Complete Guide to Webhooks — the full mental model.
TL;DR
Testing webhooks locally fails because the provider needs a public URL and your code is on localhost. A persistent capture URL plus forwarding fixes the bridge; replay and synthetic events fix the "I have to make a real payment every time" problem. One command gets you there:
npx @anonymilyhq/cli listen 3000