How to test webhooks locally without deploying
ngsrv creates a public preview link for your local service. The single biggest place that matters is webhook testing — instead of deploying to staging every time you tweak a handler, you forward the real provider's webhook straight to your laptop and debug with breakpoints.
ngsrv helps developers expose local services, share preview links, test webhooks, and get feedback faster. This is the long version of the webhook story.
What "testing webhooks locally" actually requires
A webhook is just an HTTP POST from a provider (Stripe, GitHub, Shopify, Slack, Twilio, etc.) to a URL you gave them. To run that POST against your local code, you need:
- A public HTTPS URL the provider can reach (no provider hits
localhost). - A stable URL so you don't re-paste it after every restart.
- Optional auth to keep randoms out.
- Logs you can actually read.
ngsrv ships all four. Here's the full setup.
Step 1 — Run your webhook handler locally
Pick a port. We'll use 4242 because that's the Stripe CLI's default forwarding port.
# Node / Next.js API route
npm run dev
# Python / FastAPI
uvicorn main:app --port 4242
# Go
go run ./cmd/server # set PORT=4242
Make sure the handler logs requests so you can sanity-check.
Step 2 — Start the tunnel with a reserved subdomain
ngsrv http 4242 --subdomain hooks
# -> https://hooks.tnl.ngsrv.com
Reserved subdomains stay yours between sessions on Pro and above. This is the part that matters: Stripe / GitHub / Shopify webhook URLs hate changing, so you set this once and keep it for the life of the project.
Step 3 — Point providers at the URL
Stripe
In the Stripe dashboard:
- Developers → Webhooks → Add endpoint
- URL:
https://hooks.tnl.ngsrv.com/webhook(or whatever path your handler uses) - Events: pick the ones you care about (
checkout.session.completed,invoice.paid, …)
Stripe will send a test event. Watch it land in your handler.
GitHub
For a repo:
- Settings → Webhooks → Add webhook
- Payload URL:
https://hooks.tnl.ngsrv.com/github - Content type:
application/json - Secret: (set one — see below)
- Pick "Just the push event" or "Send me everything" while developing.
Shopify, Slack, Twilio, Discord
All the same shape. URL goes into the provider's webhook configuration.
Step 4 — Verify signatures
Real webhook handlers should verify signatures. ngsrv passes the original request headers through untouched, so:
- Stripe: verify
Stripe-Signatureagainst your endpoint secret. - GitHub: verify
X-Hub-Signature-256against the webhook secret. - Slack: verify
X-Slack-Signatureagainst your signing secret.
None of this changes because of ngsrv. The bytes the provider sent are the bytes your handler receives.
Step 5 — Filter who can reach the URL
While developing, restrict the endpoint to the provider's IPs or require a header:
tunnels:
- name: webhooks
port: 4242
subdomain: hooks
security_policies:
- type: ip_allowlist
ips:
# Stripe webhook IPs (check Stripe docs for the current list)
- "3.18.12.63/32"
- "3.130.192.231/32"
# ... etc
Or pair it with a header check:
security_policies:
- type: header_required
header: "Stripe-Signature"
Both policies are free-tier.
Step 6 — Read the logs
ngsrv emits structured JSON access logs:
{
"ts": "2026-05-18T19:42:11Z",
"tunnel": "webhooks",
"method": "POST",
"path": "/webhook",
"status": 200,
"duration_ms": 42,
"req_bytes": 1822,
"headers": {
"stripe-signature": "t=...,v1=..."
}
}
Pipe to jq for tidy output:
ngsrv run ngsrv.yml | jq -r 'select(.tunnel == "webhooks") | "\(.status) \(.method) \(.path) \(.duration_ms)ms"'
This is one of the actual operational reasons to prefer a tunneling tool with stable JSON logs.
What this saves you
| | Deploy-to-staging loop | ngsrv + local handler | | --- | --- | --- | | Time per change | 5–15 minutes (CI + deploy) | Hot-reload (seconds) | | Debugger | None | Full IDE breakpoints | | Inspecting request body | Read prod logs | Read in your own process | | Cost | Staging server + CI minutes | Free tier of ngsrv |