Testing a webhook on your laptop usually starts with a tunnel: the URL changes on every restart, the provider has to be reconfigured, and half of the event types only fire against a public address. There is a simpler way: give the provider one stable catch-all URL in the cloud, let it deliver there, and read the captured request whenever you want — no open ports, no tunnel, no restarts.

Four ways to test webhooks locally, compared

Approach How it works Survives a restart Keeps retries and old events Notes
Cloud catcher (this instance, webhook.site, Pipedream's RequestBin) the provider delivers to a fixed public URL; you read the capture yes — the URL never changes yes, full history nothing runs on your machine; the payload lives where the catcher runs
Tunnel (ngrok, cloudflared, localtunnel) a public URL is proxied into a localhost port rarely — free URLs rotate on restart only what you catch live the right choice when the handler itself must run on your machine
Provider replay (Stripe “Resend”, GitHub “Redeliver”) the provider re-sends an event it already delivered yes only the events the provider still keeps needs a reachable endpoint to deliver to
Local catcher (a tool or your own script on localhost) your app posts straight to the local port yes whatever you log yourself nothing leaves your machine, but the provider cannot reach it

The practical setup is a mix: a cloud catcher to see what the provider actually sends and to keep the history, plus a tunnel or the provider's replay when you need your own handler running under the debugger.

Why a catch-all URL beats a tunnel

  • Stable address — configure the provider once, including its retries and test events; nothing breaks when you restart your laptop.
  • No ports on your machine — nothing to expose, nothing to keep running while you sleep.
  • Full history — compare the last ten attempts of the same event instead of only the one you caught live.
  • Shareable — a read-only link lets a teammate look at the payload without access to your account, and the capture is never indexed by search engines.

Step 1. Create a webhook URL

Create a webhook and copy the URL it generates. Any HTTP method and any content type is accepted: the request is stored with the method, full URL, query string, all headers, the raw body, the client IP, the user agent and the time.

Step 2. Point the provider at it

Add the URL as an endpoint in the provider's dashboard, then trigger a real event — test events do not always carry every header.

Testing Stripe webhooks locally

Developers → Webhooks → Add endpoint, paste the URL, and subscribe to the few events you care about. Then either use Send test webhook or take a real event from the dashboard and press Resend. Stripe signs each delivery: the Stripe-Signature header carries a timestamp and one or more v1= signatures over timestamp.payload, so a captured request is everything you need to debug a failing verification.

Testing GitHub webhooks locally

Repository → Settings → Webhooks → Add webhook, content type application/json, and pick the events. The Redeliver button on any delivery replays it with the same X-GitHub-Delivery id, which makes it easy to check that your handler is idempotent. If the delivery fails, the same screen shows the response your endpoint returned.

Telegram, Slack and anything else

Providers without a replay button can be triggered from the command line — or from a colleague's machine, which is the point of a public URL:

curl -X POST https://your-sandbox-host/w/YOUR_TOKEN \
  -H "Content-Type: application/json" \
  -H "X-Custom-Signature: test" \
  -d '{"event":"payment.succeeded","id":42}'

Step 3. Inspect what actually arrived

Open the request detail: query string as a table, headers, and the body pretty-printed when it is JSON. This is where most integration bugs become obvious:

  • the provider sent application/x-www-form-urlencoded, and your parser expects JSON;
  • the signature header is missing because the endpoint is in test mode (Stripe) or the secret was rotated;
  • the payload nests the object one level deeper than the docs suggested;
  • the event is a retry of one you already processed twice;
  • the request is a GET with parameters in the query string, not a POST with a body.

Signatures and raw bodies: the details that cost an afternoon

Every serious provider signs what it sends, and almost every verification bug comes from three small things:

  1. Sign the raw bytes, not the parsed object. Stripe-Signature covers timestamp + "." + raw_body; GitHub's X-Hub-Signature-256 is an HMAC-SHA256 of the raw body with your webhook secret. If you re-serialise the JSON before hashing, the bytes change (key order, spacing, unicode escaping) and the comparison fails — even though the payload looks identical in the UI.
  2. Compare in constant time, with the same algorithm. hash_equals() in PHP, hmac.compare_digest() in Python, crypto.timingSafeEqual() in Node. Comparing with == on a hex string is both a timing leak and a frequent source of case mismatches.
  3. Check the timestamp. Replay protection only works if you reject signatures older than a few minutes (t= in Stripe's header, or your own nonce elsewhere).

A catcher helps with all three: you get the exact bytes the provider sent, alongside the header values your code has to verify.

Step 4. Replay it into your local code

Copy the body and the header set from the capture and send the same request to your development server:

curl -X POST http://localhost:8000/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: <paste the captured value>" \
  --data-binary @payload.json

Because you can see the original request in full — including the byte-exact body — a failing local handler is easy to reproduce: no guessing, no waiting for the provider to fire again. If you need the handler to receive traffic from the provider itself, put a tunnel in front of it and keep the catcher as the history of what was really sent.

Tips that save time

  • If a provider retries, group requests by their event id (Stripe sends one in the header, GitHub in X-GitHub-Delivery) to spot duplicates and make the handler idempotent.
  • Give the URL to a colleague read-only: create a share link and revoke it when the debugging session ends.
  • Keep the catcher URL in your CI and staging config as well: the same address then shows whether the staging app sends what production sends.
  • For automation, the share link also answers as JSON — see Reviewing rendered emails with an AI assistant.

Next steps