CH. 02 A field guide · from the internet to localhost

How to test a webhook locally

Stripe, GitHub and every other provider send webhooks from the internet. Your localhost isn't on the internet. Here are the four ways to bridge that gap while you develop, what each one shows to whom, and the traps that make signatures fail on your laptop but nowhere else.

Can Stripe reach localhost:3000?
No
Ways around it
4
Slowest answer allowed
3–10 s
Fig. 01 — why the webhook never arrives, and the way aroundLive
A webhook can't reach localhost directly, but a tunnel gets it there A provider's server in a data center tries to send a request straight to a laptop. The home router drops it, because nothing on the internet can open a connection to a laptop behind it, and localhost only exists on the laptop. Below, the laptop opens an outbound connection to a tunnel's public edge. The provider sends the webhook to the edge's public URL, and the request travels back down the connection the laptop opened. DATA CENTER · THE INTERNET Stripe POST /webhook · evt_1Q… YOUR ROUTER NAT · firewall drops unsolicited YOUR LAPTOP localhost :3000 /webhook ✕ NO WAY IN: THE ROUTER DROPS IT AND "localhost" WOULD MEAN STRIPE'S OWN SERVER THE WAY AROUND · YOUR LAPTOP DIALS OUT FIRST TUNNEL EDGE · PUBLIC https://abc.example- tunnel.dev/webhook or: stripe listen PROVIDER Stripe YOUR LAPTOP tunnel client → :3000 1 · THE LAPTOP DIALS OUT TO THE EDGE 2 · WEBHOOK TO THE PUBLIC URL 3 · DELIVERED DOWN THE SAME CONNECTION → 200 OK
Top: the request (●) has nowhere to go. Your router only lets in replies to connections your laptop started, and if you typed localhost into the provider's settings, it points at the provider's own machine. Bottom: a tunnel or provider CLI has your laptop start the connection, so the webhook can ride back down it.

§ 01 The short answer

Testing webhooks locally, in one paragraph

Quick answer

A provider can't call localhost, so something has to give your laptop a public address or carry the events to it. If the provider has a CLI that forwards events (Stripe, GitHub), use it: stripe listen --forward-to localhost:3000/webhook. Otherwise, run a tunnel such as ngrok or a Cloudflare Quick Tunnel, and register its public URL with the provider.

To see a real payload before you write any code, capture one with a webhook tester and replay it to localhost with curl. And once the handler works, stop tunneling: deploy it to a URL that doesn't depend on your laptop being awake.

Spec sheet Key facts

Why it fails
localhost exists only on your machine; routers drop unsolicited inbound connections
Best fix
The provider's own CLI, when it has one
Any provider
A tunnel to localhost:3000
Time to answer
Slack 3 s · Shopify 5 s · GitHub 10 s
Retries
Stripe up to 3 days (live) · Shopify 8 times in 4 h · Slack 3 times · GitHub none
Signatures
Over the raw bytes; parsing first breaks them

§ 02 Your options

Four ways to get a webhook to your laptop

They aren't rivals. Most people use two or three at different moments: capture to see the payload, forward while coding, deploy when it's done.

A · Best when available

The provider's own CLI

Stripe and GitHub ship command-line tools that log in to your account, receive events over an outbound connection, and forward them to a local URL. No public URL to register, and the events are real.

Use it for Stripe (stripe listen) and GitHub repository or organization webhooks (gh webhook forward).

Watch out: stripe listen signs with its own secret, not your dashboard endpoint's. gh webhook forward works for one person per repository at a time, and not for GitHub App webhooks.

B · Any provider

A tunnel

A small client on your laptop dials out to a public edge, which gives you an HTTPS URL. Requests to that URL come back down the connection to localhost:3000. You register the URL with the provider like any endpoint.

Use it for providers without a CLI, and for testing the whole path including your route and middleware.

Watch out: the URL is public, and some tunnels hand out a new one every run, which you then re-register.

C · Before you write code

Capture, then replay

Point the provider at a webhook tester, trigger an event, and read exactly what arrives. Then replay that request against your handler with curl, as often as you like, without triggering anything.

Use it for learning a payload's shape, saving fixtures for tests, and debugging one tricky event.

Watch out: signed requests carry a timestamp. Libraries reject them after about five minutes, so replays of old captures fail verification by design.

D · When it has to keep working

Deploy a dev receiver

Run the handler on a server with a permanent URL, in a sandbox or staging setup. It receives events when your laptop is closed, and the URL never changes, so you register it once.

Use it for anything a teammate, a demo or a long-running integration depends on.

Watch out: you lose your local debugger. Log each event's ID and type so you can follow along.

Relays that re-encode bodiesSome free relays forward a webhook by parsing its JSON and writing it out again. The smee.io client does this: whitespace and escapes can change, form bodies turn into JSON, and the HMAC signature over the original bytes no longer matches (smee-client#325). It's fine for seeing events; for signature checks, use the provider's CLI or a tunnel.

§ 03 Side by side

ngrok vs Cloudflare Tunnel vs Stripe CLI vs the rest

What each tool needs, how long its URL lasts, and who can see your payloads. Checked against each vendor's documentation in October 2026; free plans change, so follow the links before you rely on a limit.

ToolTypeAccountURL lastsRuns your codeSignatures check outWorth knowing
Stripe CLIProvider CLIStripeNo URL neededYesYes, with the CLI's own whsec_stripe trigger sends test events on demand
gh webhook forwardProvider CLIGitHubNo URL neededYesOnly if you pass --secretRepo and org webhooks only; one user per repo at a time
ngrokTunnelFree accountStable: one free dev domainYesYesFree plan: 20,000 requests a month; browser warning page doesn't affect webhooks
Cloudflare Quick TunnelTunnelNoneNew URL every runYesYesFor testing only; up to 200 requests in flight
Tailscale FunnelTunnelTailscaleStable while the device name isYesYesBeta; public ports 443, 8443 and 10000 only
smee.ioRelayNoneStable channelYesOften not: re-encodes the bodyAnyone with the channel URL sees payloads
Webhook testerCaptureNoneAs long as you use itNo; replay with curlChecks them in your browserShows the exact bytes and headers
A deployed receiverDeployDepends on the hostPermanentYesYesKeeps working with your laptop closed

Also: the Svix CLI's svix listen http://localhost:3000/webhook gives a public relay URL with no account, and forwards POST requests.

§ 04 Step by step

The commands, for each way

All of them assume your app listens on port 3000 and handles webhooks at /webhook. Change both to match yours.

Stripe CLI

Terminal
# Once: log in to your Stripe account
stripe login

# Forward events to your handler. It prints a signing secret:
# "Your webhook signing secret is whsec_…" — use that one locally
stripe listen --forward-to localhost:3000/webhook

# In a second terminal: send a test event
stripe trigger payment_intent.succeeded

GitHub CLI

Terminal
# Once: install the extension
gh extension install cli/gh-webhook

# Forward a repository's issue events to your handler.
# --secret makes GitHub sign deliveries, so you can test verification.
gh webhook forward --repo=OWNER/REPO --events=issues \
  --url=http://localhost:3000/webhook --secret=dev-secret

ngrok

Terminal
# Once: sign up at ngrok.com and add your authtoken
ngrok config add-authtoken <your-token>

# Expose port 3000; register https://<your-dev-domain>/webhook with the provider
ngrok http 3000

Cloudflare Quick Tunnel

Terminal
# No account needed. Prints https://<random-words>.trycloudflare.com
cloudflared tunnel --url http://localhost:3000

# Register https://<random-words>.trycloudflare.com/webhook with the provider.
# The URL changes every time you restart cloudflared.

Capture & replay

Terminal
# 1. Open whatiswebhook.com/webhook-tester.html and copy your URL
# 2. Register it with the provider and trigger an event
# 3. In the tester, open "Replay locally" and run the command it shows:
printf '%s' '{"id":"evt_1","type":"payment_intent.succeeded"}' | \
curl -X POST 'http://localhost:3000/webhook' \
  -H 'Content-Type: application/json' \
  -H 'Stripe-Signature: t=…,v1=…' \
  --data-binary @-

The handler, done right

Whichever way the event arrives, the handler is the same. Verify the signature over the raw body, answer fast, then do the work. Here with Stripe's own libraries:

Node · Express

server.js
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

// The raw body on this route only: the signature covers the exact bytes.
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Bad signature: ${err.message}`);
  }
  res.sendStatus(200);      // answer first…
  handle(event);            // …then work, and skip event.id values you've seen
});

app.use(express.json());    // JSON parsing for every other route, after the webhook
app.listen(3000);

Python · Flask

app.py
import os
import stripe
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhook")
def webhook():
    # get_data() is the raw body; request.json would be too late
    payload = request.get_data()
    try:
        event = stripe.Webhook.construct_event(
            payload, request.headers.get("Stripe-Signature"),
            os.environ["STRIPE_WEBHOOK_SECRET"])
    except (ValueError, stripe.error.SignatureVerificationError):
        return "Bad signature", 400
    queue_for_later(event)  # keep the response fast
    return "", 200

app.run(port=3000)

§ 05 The traps

What goes wrong only on your laptop

01

You registered http://localhost:3000

The provider's delivery log: connection refused · timed out · invalid URL

The provider resolves localhost on its server, so it calls itself. Some providers reject such URLs outright. Register the tunnel's public URL, or use a CLI that needs no URL. Why localhost works this way: whatislocalhost.com.

02

The signature fails, but only here

No signatures found matching the expected signature for payload · 401 · 400 Bad signature

Three usual causes, in order of likelihood:

  1. The body was parsed before you checked it. Middleware like express.json() turns the bytes into an object; writing it back out doesn't reliably give the same bytes. Give the webhook route the raw body.
  2. The wrong secret. Events forwarded by stripe listen are signed with the secret it prints, not the dashboard endpoint's. Sandbox and live secrets differ too.
  3. A relay re-encoded the body. See smee.io above. Capture the same event in the tester and check the signature there: if it passes there and fails in your app, the bytes changed on the way.
03

Every event arrives twice (or five times)

The same event ID in your logs, again and again, while you debug

A breakpoint holds the request open, and the provider gives up after its time limit: 3 seconds for Slack, 5 for Shopify, 10 for GitHub. It marks the delivery failed and, except for GitHub, sends it again. Answer 200 before you do any slow work, and record which event IDs you've handled so a retry does nothing. Dedupe keys for each provider →

04

It worked yesterday

Deliveries fail with 404 or DNS errors after a restart

Your tunnel's URL changed. Cloudflare Quick Tunnels pick a new hostname every run, and so do tunnels on plans without a fixed domain. Re-register the new URL, use a tunnel with a stable domain, or deploy the receiver so the URL stops depending on your laptop.

05

Your app redirects the webhook

301 · 302 · 308 in the delivery log

A redirect from http to https, or from /webhook to /webhook/, is a failure to the provider: Stripe counts redirects as failed deliveries. Register the exact final URL. More on redirects →

06

The framework rejects the POST

403 Forbidden · CSRF verification failed · InvalidAuthenticityToken

Rails, Django, Laravel and others protect POST routes with CSRF tokens that a provider can't have. Exempt the webhook route, and rely on the signature check instead. CSRF, logins and firewalls →

07

Forgetting the tunnel is public

Requests in your logs that no provider sent

Anyone who finds the URL can call your laptop. Verify signatures in development too, don't serve admin pages on the same port, and stop the tunnel when you're done.

§ 06 When to stop tunneling

A tunnel lives as long as your laptop is awake.
Give your webhook a real home.

One sentence · no dashboard
A Your handler works locally?

Ask your coding agent

Open a terminal in your project folder and paste one of these. The agent packages your code, deploys it, and hands you back a permanent HTTPS URL to register once.

$claude "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"
$codex "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"

Keep the signing secret out of your code: the agent can store it as an encrypted secret on the server.

B Starting from zero?

Have one built and deployed

Chat assistants deploy for you when they can run code with internet access; otherwise they'll give you the exact commands.

§ 07 FAQ

Frequently asked questions

Can Stripe send webhooks to localhost?

Not directly. Stripe sends webhooks from its servers on the internet, and localhost only exists on your own computer. Use the Stripe CLI (stripe listen --forward-to localhost:3000/webhook), which receives events over an outbound connection and forwards them to your machine.

Do I need ngrok to test webhooks?

No. If the provider has a CLI that forwards events (Stripe, GitHub), use that. For anything else, a Cloudflare Quick Tunnel needs no account, and you can capture a real request with a webhook tester and replay it to localhost with curl.

Why does webhook signature verification fail locally?

Usually one of three things: your framework parsed the JSON before you verified it, so the bytes changed; you're using the dashboard's signing secret while the events come through a CLI that signs with its own; or a relay re-encoded the body on the way. Verify against the raw body with the secret of the path the event actually took.

Does GitHub retry failed webhook deliveries?

No. GitHub does not automatically redeliver failed deliveries; you redeliver them from the webhook's Recent Deliveries tab or the REST API. Stripe, Shopify and Slack do retry, so a slow or failing local handler gets the same event again.

Why do I get duplicate webhooks when debugging?

A breakpoint holds the request open past the provider's time limit (3 seconds for Slack, 5 for Shopify, 10 for GitHub), so the provider marks the delivery failed and retries it. Answer 2xx first and do the work afterwards, and ignore event IDs you've already handled.

Is it safe to expose localhost with a tunnel?

A tunnel URL is public: anyone who finds it can send requests to your laptop. Verify signatures even in development, don't expose admin routes on the same port, and close the tunnel when you're done.