How to test webhooks locally: the full workflow
Webhook senders only POST to public HTTPS URLs, so local testing means bridging to localhost. The workflow: capture a real delivery in a free bin, drive your local handler with a provider CLI or tunnel, verify the signature, then replay and test idempotency.
Every webhook integration hits the same wall: the sender — Stripe, GitHub, Shopify, whoever — will only POST to a public HTTPS URL, and your handler runs on localhost where nothing on the internet can reach it. Most "webhook debugging" is really just bridging that gap. This is the workflow that works regardless of provider: observe → drive → verify → replay.
The core problem
Three constraints collide. Senders require a publicly reachable URL with a valid TLS certificate (no self-signed). Your code isn't public. And the interesting bugs — wrong signature handling, parsing surprises, retry storms — only show up with real traffic, which you can't fabricate convincingly by hand because you don't hold the sender's signing key. So the game is: get real requests flowing, watch them exactly as sent, and feed them into your code.
Step 1: Observe — see the request exactly as sent
Before writing a single line of handler code, point the sender at a request bin and look at ground truth. Create a free bin at view.webhooks.page — no signup, the URL works immediately — and paste it into the provider's webhook settings.
Trigger one event and inspect what actually arrived:
- The raw body, byte-for-byte. This is what signature verification must consume — not your framework's parsed-and-reserialized version.
- The headers: the signature header (
Stripe-Signature,X-Hub-Signature-256,X-Shopify-Hmac-SHA256,X-Twilio-Signature…), the content type, event-type headers, delivery IDs. - The shape across event types. Trigger a few different events; payloads vary more than docs suggest.
Save (or copy out) one captured request per event type you care about. They become your test fixtures for steps 3 and 4. Bins expire after 48 hours, so grab what you need.
Step 2: Drive — get real traffic into your code
Three patterns, in rough order of preference when available.
Provider CLIs (best when they exist)
Some vendors ship first-party forwarding tools — use them; they handle reconnects and use the provider's own event stream:
- Stripe:
stripe listen --forward-to localhost:3000/webhooks/stripe, plusstripe trigger payment_intent.succeededto fire events on demand. Note: the CLI prints its ownwhsec_…signing secret for the session, different from your Dashboard endpoint's. Full walkthrough in the Stripe playbook. - GitHub: the CLI extension
gh webhook forward—gh extension install cli/gh-webhook, thengh webhook forward --repo=you/repo --events=issues,pull_request --url="http://localhost:3000/webhooks/github". Repo and org webhooks only, and explicitly dev-only — not for production. Details in the GitHub playbook. - Shopify:
shopify app webhook trigger --topic=orders/create --address=https://… --delivery-method=httpsends a sample payload to any HTTPS address (so combine it with a tunnel for localhost). Caveats from Shopify's docs: the payload is always the same sample, and failed deliveries aren't retried — it's a smoke test, not a full simulation.
Tunnels (the general case)
No vendor CLI? Put a public URL in front of your local port. Two solid options that need no account:
# Cloudflare quick tunnel — random https://*.trycloudflare.com URL, no signup
cloudflared tunnel --url http://localhost:3000
# localhost.run — reverse SSH tunnel with TLS, uses the ssh client you already have
ssh -R 80:localhost:3000 nokey@localhost.run
Paste the tunnel URL into the provider's webhook settings and real deliveries hit your local handler directly — breakpoints and all. More options and trade-offs in testing webhooks without ngrok. One caveat: several providers (Twilio being the notorious one) sign the URL itself, so a delivery signed for your tunnel hostname won't re-verify against a different URL later. Capture-and-replay with care there.
Mailbox + forward (async)
Point the sender at a hosted endpoint, let deliveries accumulate, and relay them to your machine when you're ready. With webhooks.sh Solo ($12/month), wsh listen my-endpoint --to localhost:3000 does exactly that and picks up where it left off after a restart. Free bins at view.webhooks.page cover the mailbox half only.
Step 3: Verify signatures locally
This is where most webhook code is wrong, and where your step-1 fixtures pay off. You can't generate a valid signature yourself (no secret), but you captured a real one — replay the captured request, unmodified, against your local handler:
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1756…,v1=…" \
--data-binary @captured-request.json
If your handler accepts the real signature, the crypto is right. Then the rules that keep it right:
- Verify the raw bytes. Mount a raw body parser on the webhook route (
express.raw()) before any JSON middleware. Re-serialized JSON changes whitespace and key order, and the HMAC breaks. - Each provider signs different data. Raw body for Stripe, GitHub, and Shopify; URL plus sorted form params for Twilio; timestamp + raw body under Ed25519 for Discord interactions. Do not reuse validators across providers.
- Use the constant-time compare (
crypto.timingSafeEqual) and the timestamp tolerance the provider recommends — it's your replay-attack window. - Want to sanity-check the math before touching code? Paste the captured header and body into the HMAC signature verifier (runs in your browser, nothing leaves it), or generate synthetic payloads with the payload generator.
Step 4: Replay and idempotency
Real senders retry. Anything non-2xx (or too slow) gets re-delivered — Stripe retries for up to 3 days with backoff. Two consequences:
- Test the retry path. Return a 500 from your local handler on purpose and watch the same event come back. If that doesn't happen locally, you haven't tested retries — you've tested the happy path.
- Dedupe on the event ID. Store the delivery/event ID (
evt_…for Stripe,X-GitHub-DeliveryGUID,X-Shopify-Webhook-Id) of everything you've processed and no-op duplicates. Assume at-least-once delivery, because that's what everyone actually provides. Ordering isn't guaranteed either — fetch current state from the API rather than trusting event sequence.
Worked example: Stripe, zero to verified local handler
- Observe. Create a bin at view.webhooks.page. In the Stripe Dashboard (test mode): Developers → Webhooks → Add endpoint, paste the bin URL, subscribe to
payment_intent.succeeded. Runstripe trigger payment_intent.succeededand read the raw request — body andStripe-Signatureheader — in the bin. - Drive. Start your app, then
stripe listen --forward-to localhost:3000/webhooks/stripe. Copy thewhsec_…it prints (the CLI's secret, not the Dashboard endpoint's). - Handle. Route with raw body +
stripe.webhooks.constructEvent(rawBody, signatureHeader, cliSecret), or hand-rolled HMAC-SHA256 overt + "." + rawBodywith a 5-minute timestamp tolerance andtimingSafeEqual. Return 200 fast; do slow work after acknowledging. - Verify.
stripe triggeragain; your handler should accept. Then replay the step-1 fixture against the handler with curl and a wrong secret — it must reject. Both directions proven. - Replay. Trigger the same event twice (or force a 500 once) and confirm your
evt_…dedupe swallows the duplicate cleanly.
That loop — observe on the wire, drive your code, verify both directions, test the retry — transfers to every provider on this site. The endpoints change; the workflow doesn't.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.