If you've ever tried to build a Stripe checkout flow or a GitHub Actions integration, you've run into this: webhook providers need a publicly reachable URL to send events to, and localhost:3000 is definitely not that.
There are two practical ways to solve this, and which one you reach for depends on where you are in the development cycle.
Catch and Inspect: Understand the Payload First
Before you write a single line of handler code, you need to know what you're working with. The catch-and-inspect workflow is exactly what it sounds like — point your provider at a CatchHook endpoint, trigger the event, and look at what shows up.
This is surprisingly valuable. Most webhook documentation is either incomplete or wrong about edge cases. Seeing the real payload from Stripe or GitHub before you start writing the handler saves a lot of "why is this field null?" debugging later.
Getting started
- Create a CatchHook endpoint — or just use the live demo, which doesn't even require an account
- Copy the endpoint URL and drop it into your provider's webhook settings
- Trigger the event (create a test payment, push a commit, whatever)
- The request shows up in CatchHook within seconds with full headers, body, and provider-specific context
From here you can see exactly what the provider sends — event type, signature headers, the full JSON body — and build your handler against real data instead of documentation examples.
Editing captured payloads
Once you've captured a real event, you can edit the body, headers, and query params to create test variations. Need to test what happens when the amount is zero? Or when a field is missing? Edit the captured payload and you've got your test case without having to trick the provider into generating that exact scenario.
For providers with signature verification, CatchHook can re-sign the edited payload so your handler still validates it.
Live Tunneling: Get Webhooks Delivered to Your Machine
When you're ready to actually test your handler end-to-end — your local server receiving and processing real webhook traffic in real time — you need a tunnel. The CatchHook CLI opens a secure WebSocket connection and streams incoming events directly to your local machine.
Localhost delivery follows a different path from dashboard replay and Endpoint Action Forward steps: use the CatchHook CLI tunnel. It opens a secure connection from your machine and streams incoming events directly to your handler while keeping the captured request and delivery history visible in CatchHook.
How it works
- The CLI connects to CatchHook over WebSocket
- When a webhook arrives at your endpoint, it streams to the CLI in real time
- The CLI delivers it to your local URL with the original method, headers, and body
- Delivery results get reported back to CatchHook so you can monitor from the dashboard
Starting a tunnel
npx @catchhook/tunnel start --endpoint ep_abc123 --port 3000
First time? The CLI opens a browser window for login automatically. For CI or headless environments, you can pass a token directly:
npx @catchhook/tunnel start --endpoint ep_abc123 --port 3000 --token chk_dev_xxx
Temporary endpoints work without an account at all:
npx @catchhook/tunnel ep_abc123 --key tkey_abc123 --port 3000
Provider presets
This is where CatchHook's tunnel really shines compared to generic tunneling tools like ngrok. The --provider flag creates an endpoint with provider-specific configuration baked in:
npx @catchhook/tunnel start --provider stripe --port 3000
This generates a signing secret, creates a signature verification config, sets the default webhook path (/webhooks/stripe), and prints the exact setup instructions for pasting the URL into Stripe's dashboard. No digging through docs to figure out what goes where.
With a generic tunnel, you get a URL. With CatchHook's tunnel, you get a URL plus the intelligence to actually understand the traffic flowing through it — provider detection, event type parsing, signature verification, and replay warnings all work automatically.
When to Use Which
| Scenario | Approach |
|---|---|
| Building a handler from scratch | Catch and inspect first |
| Understanding a provider's payload shape | Catch and inspect |
| Testing the full delivery flow | Live tunnel |
| Running automated integration tests | Live tunnel |
| Reproducing a production bug | Catch and inspect (compare events) |
| Demoing to a teammate | Catch and inspect (stored events) |
Most development sessions end up using both: catch and inspect to understand the payload and iterate on handler logic, then a tunnel to verify the end-to-end flow actually works.
Common Local Testing Failures
Signature verification fails on tunnel-forwarded events
If your handler validates webhook signatures and you're editing payloads before re-triggering, the original signature won't match the modified body. Use the Re-sign option when editing in CatchHook — it recomputes the signature with your configured secret.
For early development, you might want to temporarily skip signature verification in your dev environment. Just don't forget to turn it back on.
Connection refused
The tunnel CLI reports ECONNREFUSED when your local server isn't running or is on a different port. Quick sanity check:
curl http://localhost:3000/webhooks/stripe
If that errors out, your server isn't listening where you think it is.
Events arrive out of order
Webhook providers don't guarantee ordering. If your handler depends on event sequence (e.g., checkout.session.completed arriving before invoice.paid), you're going to have a bad time. Use the event's timestamp or sequence ID rather than arrival order. CatchHook captures exact arrival times so you can compare what the provider sent versus what your handler saw.
Duplicate events during testing
Providers retry failed deliveries aggressively. If your tunnel disconnects briefly and reconnects, you might get the same event forwarded twice. Build idempotency into your handler from day one — check for duplicate event IDs before processing side effects. See Webhook Retries and Idempotency for the patterns.