The first time most developers integrate a webhook, the workflow looks like this: write the handler, deploy to staging, wait for the provider to fire an event, read the logs, redeploy. Each iteration is two to ten minutes long. Each iteration costs a real event in your provider's system. Each iteration is the wrong way to develop software, and yet it is how most webhook integrations get built.
The good news is that the hard problem — getting the provider to reach a server running on your laptop — has at least three solid solutions, and the rest of the workflow falls out naturally once that is solved.
The tunnel is the load-bearing part
You need a public URL that forwards to localhost:8000. The classic answer is ngrok, which still works and gives you a free static URL on the paid tier. Cloudflare Tunnel is free and faster on most networks but requires a domain. SSH reverse tunnels work if you control a public server. Tailscale Funnel is the newest entrant and is excellent if your team already runs Tailscale. Pick one, learn it, stop changing.
The trap is treating the tunnel as ad-hoc. Configure it as a script in your repo: ./scripts/tunnel.sh that starts the tunnel, prints the public URL, and updates your provider's webhook configuration via their API. The friction of running ad-hoc tunnel commands is the friction that makes people give up on local webhook development and resort to staging deploys.
Capture before you code
Before writing any handler, capture five real webhook events from your provider's test mode and save them as JSON files in a fixtures directory. Use WebhookVault, webhook.site, or any catch-all endpoint to receive a few real payloads. These fixtures are the input to every test you will ever write against this integration.
The reason is that webhook payloads are surprisingly structured at one level and surprisingly weird at another. Field names you assumed exist may be nested. The amount you assumed was an integer is a string in some events. The signature header you assumed is base64 is hex. Real captured payloads tell you these things in five minutes; reading the docs and writing speculative code tells you these things in three days.
Replay locally, in a loop
Once you have fixtures, build a small script that POSTs them to your local handler with the correct signature header. The signature should be computed from the body and your test signing secret. This gives you a fast inner loop: change handler code, run ./replay.sh checkout-completed, see the result. No tunnel, no provider involvement, no waiting.
This is also where you build idempotency tests. Run the same fixture twice and assert that the second call has no observable side effect. Most webhook bugs are duplicate-handling bugs, and they are easy to write tests for once you can replay events on demand.
Use the tunnel for the integration check, not the dev loop
The tunnel exists so that, after you have made your handler work against fixtures, you can verify that the provider's actual signature, content-type, and retry behavior are what you expected. This should be a five-minute confirmation step, not the way you discover bugs.
This pattern — develop against fixtures, verify against the tunnel, deploy — cuts the typical integration time from days to hours. It also produces a far better test suite, because the fixtures it leaves behind keep working forever.
Logging that actually helps
Webhook handlers are the place where production bugs are silent for hours before anyone notices. Log every incoming request at INFO level with the event ID, event type, and a redacted summary of the body. Log signature verification failures at WARN with the source IP. Log handler completion at INFO with the duration. Do not log full payloads at INFO — they leak customer data into your aggregator. Log them at DEBUG, gated behind a per-environment toggle.
If you are already logging request IDs (you should be), include the provider's event ID in your structured log fields. When a customer reports a billing problem, the support engineer can grep the event ID and find every line of yours that handled it. This single field saves more time per quarter than every other piece of observability work combined.
Test what happens when you are slow
Every webhook provider has a timeout, usually between 5 and 30 seconds. If your handler is slower, the provider thinks the call failed and retries. This produces duplicates, which produces customer-facing bugs that are hard to reproduce.
Write a test that runs your handler with a 100ms ceiling. If it cannot meet that, the handler is doing too much synchronously: ack fast, queue the work. The right pattern is to verify the signature, persist the event, return 200, and process asynchronously. Anything more, and you are gambling against your provider's timeout.
Production debugging needs a vault
The bug that escapes your fixtures and your tunnel is the bug that fires once in production at 3 AM, two minutes before a customer complaint. You cannot debug that without seeing the actual headers and body. Run a webhook capture endpoint in production that mirrors a sample of your live traffic to a separate inspection service. We use WebhookVault for this; the result is that when a customer says "the order webhook didn't fire," we can search by timestamp and see exactly what happened, headers and all.
The right workflow, one paragraph
Capture five real events, save as fixtures. Build a replay script that signs and posts them locally. Build the handler against fixtures, with idempotency tests. Run a tunnel for an integration check. Add structured logging with the event ID. Mirror a sample of production traffic to a webhook inspection service. Sleep through your weekends.