Webhooks are backwards from everything else you build. Normally your code makes the call. With a webhook, some outside service — Stripe, GitHub, your own backend — calls you, dropping a JSON payload on your doorstep the second something happens. And very often that webhook's whole reason for existing is to send an email: "Your order shipped," "New comment," "Payment received." Testing that on a laptop means solving two annoying problems at once — an inbound request that can't reach your machine, and an outbound email you need to actually receive.

Problem 1: Nothing Can Reach localhost

Stripe can't POST to localhost:3000 — it is not on the internet. You need a tunnel: a public URL that forwards to your machine. With ngrok it is one line:

bash

That prints a public URL like https://a1b2c3.ngrok.app. Paste it into the provider's webhook settings and their requests now hit your local handler — breakpoints and all. Cloudflare Tunnel does the same thing for free if you would rather not sign up for another service.

Problem 2: What Is Even in the Payload?

Before you write a line of handler code, look at the actual request. webhook.site gives you a throwaway URL that captures anything sent to it and shows the full headers and body. A trimmed Stripe-style payload looks like this:

json

Notice that customer_email is nested two levels down — exactly the kind of thing that causes a null crash if you guess at the shape. Paste the payload into our JSON Validator to confirm it is well-formed, and if there is a signed token inside, decode it with our JWT Decoder.

Verify the Signature (Do Not Skip This)

Anyone who knows your tunnel URL can POST fake events to it, so a real handler checks a signature header before trusting anything. Both Stripe and GitHub sign their deliveries and document exactly how to verify them:

javascript

Test the unhappy path too: send a deliberately bad signature and make sure your handler returns a 400 instead of quietly processing it.

Problem 3: The Email It Sends

Here is the step people skip. The handler receives the event, and then it sends an email. To test the whole flow end to end you need to receive that email — and you do not want it landing in your real inbox on every single test run. This is where a disposable email earns its place: use one as the test customer's address, trigger the event, and watch the confirmation arrive in real time. When you need a clean run, grab a fresh address and go again.

Replay Instead of Recreating

Most providers retry failed deliveries and let you replay past events from their dashboard. Stripe's CLI can forward and re-fire events straight to your tunnel:

bash

Reproduce a bug by replaying the exact event that caused it, instead of trying to recreate the conditions by hand.

The Whole Loop

Put it together and local webhook testing looks like this: start your server and open a tunnel, point the provider's webhook at the tunnel URL, inspect the first payload and validate the JSON, verify the signature including the failure path, use a throwaway inbox to receive whatever email the handler sends, and replay events to re-test without redoing the setup. Nail that loop once and webhooks stop being the scary part of an integration — you get fast feedback, real payloads, and a full view from "event fired" all the way to "email landed," without leaving your laptop.