FeaturesGuidesBlogDocumentationPricingGet Started
Webhooks

Why Webhooks Fail on Localhost (And How to Fix It in 60 Seconds)

Webhook delivery fails because localhost is not reachable from the internet — not because your handler code is wrong.

Your webhook did not fail — localhost did

You wrote the handler. You return 200. Yet Stripe shows "failed delivery" or GitHub shows no recent delivery at all.

The most common cause: the provider cannot reach localhost. Your laptop is not on the public internet. Webhook POSTs need a URL they can resolve and connect to over HTTPS.

This is not a bug in your code. It is a networking constraint every developer hits.

The 60-second fix

Expose your local server with a public HTTPS URL:

npm run dev
npx localtolink

Register the tunnel URL plus your handler path in the provider dashboard:

https://YOUR-SUB.localtolink.com/webhook

Trigger a test event. Watch your terminal logs. Done.

Diagnose before you debug code

SymptomLikely causeFix
No delivery attempts in dashboardWrong URL registeredCopy exact tunnel URL + path
Connection timeoutTunnel not runningRestart npx localtolink
404 on deliveryPath mismatchMatch /webhook vs /webhooks
401/403Signature verification failedUse raw body; check webhook secret
Works once, fails after restartURL changedUpdate dashboard with new subdomain
503 from tunnelSession expired (2h limit)Restart tunnel

Provider-specific tips

Stripe — Use the exact signing secret from the Dashboard endpoint. Stripe CLI is fastest for iteration; a tunnel URL works for Dashboard endpoint testing.

GitHub — Check Recent Deliveries for response codes. GitHub sends a ping on setup — your handler must respond 200.

Shopify — HMAC verification requires the raw request body. Do not parse JSON before verifying.

Twilio — Voice and SMS webhooks need publicly reachable URLs. Media Streams require WebSocket support.

Why not just use ngrok / other tools?

Any tunnel that gives you a public HTTPS URL solves the core problem. The difference is setup friction:

LocalToLink optimizes for anonymous, npm-native quick tests. Choose based on your workflow, not hype.

Workflow that prevents URL churn

  1. Start your app and tunnel together at the start of a dev session
  2. Register the tunnel URL once in the provider dashboard
  3. Use provider "resend" or "replay" features instead of re-registering
  4. When the tunnel restarts, update the dashboard URL — one copy-paste

For Stripe specifically, stripe listen --forward-to localhost:3000/webhook avoids URL registration entirely during active development.

Security during webhook testing

While a tunnel runs, your local handler is reachable from the internet:

Step-by-step: Stripe webhook through a tunnel

This is the flow most developers repeat daily. Terminal 1 runs your app; Terminal 2 runs the tunnel:

npm run dev
npx localtolink --port 3000

In Stripe Dashboard → Developers → Webhooks → Add endpoint, paste:

https://YOUR-SUB.localtolink.com/api/stripe/webhook

Select events (e.g. checkout.session.completed), copy the signing secret into .env, and trigger a test payment. Stripe POSTs to your tunnel; your handler verifies the signature and returns 200.

If Stripe shows "failed" with no response body, the request never reached your handler — check the tunnel URL and path first, not your business logic.

Raw body and signature verification

Most webhook failures after connectivity are fixed are signature-related. Frameworks that parse JSON before verification break HMAC validation because the signature is computed over the raw bytes of the request body.

In Express, use express.raw({ type: 'application/json' }) on the webhook route only. In Next.js App Router, read await request.text() before JSON.parse. In Fastify, register a content-type parser that preserves the raw buffer.

Log the stripe-signature or x-hub-signature-256 header once during setup to confirm headers arrive intact through the tunnel — they do, because LocalToLink forwards requests transparently.

Two valid Stripe workflows

Stripe CLI (stripe listen --forward-to localhost:3000/webhook) — no tunnel URL registration; events appear in terminal. Best for rapid Stripe-only iteration.

Tunnel + Dashboard endpoint — tests the exact production code path including HTTPS URL registration. Best before shipping or when multiple providers share one app.

Many teams use CLI for daily dev and a tunnel for integration tests. Your handler code stays identical.

When the dashboard says "connection refused"

The provider reached LocalToLink but your app was not listening on the configured port. Confirm:

A quick curl http://localhost:3000/webhook from your machine validates the app; curl https://YOUR-SUB.localtolink.com/webhook validates the full chain.

Honest limits

LocalToLink free tier: 2-hour sessions, random subdomain, 1 GB/session, 10 req/s. Fine for webhook debugging. Not for production endpoints or load tests.

FAQ

Does localhost work with webhook signature verification?
Yes. The tunnel forwards the full request including headers and raw body.

Can I test multiple webhooks at once?
One tunnel per IP on free tier. Run handlers on different paths of the same app, or use separate ports with separate tunnel sessions.

Why did it work yesterday?
Tunnel subdomain changed on restart, or session hit the 2-hour limit.

Next steps

Try it now

Start a tunnel from your project directory — no account required.

$ npx localtolink

See the full CLI reference for flags and exit codes.

LocalToLink CLI 1.0.1 — verified 2026-09-05