Why Webhooks Fail on Localhost (And How to Fix It in 60 Seconds)
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
| Symptom | Likely cause | Fix |
|---|---|---|
| No delivery attempts in dashboard | Wrong URL registered | Copy exact tunnel URL + path |
| Connection timeout | Tunnel not running | Restart npx localtolink |
| 404 on delivery | Path mismatch | Match /webhook vs /webhooks |
| 401/403 | Signature verification failed | Use raw body; check webhook secret |
| Works once, fails after restart | URL changed | Update dashboard with new subdomain |
| 503 from tunnel | Session 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:
- Some tools require account signup before the first URL
- Some need binary installs or config files
- Some change reliability under load on shared infrastructure
LocalToLink optimizes for anonymous, npm-native quick tests. Choose based on your workflow, not hype.
Workflow that prevents URL churn
- Start your app and tunnel together at the start of a dev session
- Register the tunnel URL once in the provider dashboard
- Use provider "resend" or "replay" features instead of re-registering
- 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:
- Do not expose apps with real user data during testing
- Use test mode API keys (Stripe test mode, GitHub test repos)
- Verify webhook signatures — never skip HMAC checks in dev
- Stop the tunnel when you are done
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:
- Dev server is running on the same port passed to
--port - Docker: tunnel must target the host port mapped from the container (
-p 3000:3000) - Firewall on your machine is not blocking localhost connections
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
- Test webhooks locally — step-by-step for multiple providers
- Stripe webhooks on localhost — signing secrets and raw body
- Expose localhost in one command — zero-config setup
Try it now
Start a tunnel from your project directory — no account required.
$ npx localtolink