Testing Webhooks Locally with ngrok and Tunnels: A Step-by-Step Walkthrough
You have a webhook handler running on your laptop and a provider — Stripe, GitHub, a partner API — that needs to deliver signed events to it. This walkthrough takes you from nothing to a verified delivery hitting a breakpoint, using a tunnel to bridge the public internet and localhost. It is the concrete, command-level companion to local webhook development with tunnels; read that first for the underlying patterns and trade-offs. The same capture habit you build here pays off later when you need to debug failed webhook deliveries in production.
Prerequisites
- A webhook handler that listens on a local port (the FastAPI example below uses
:8000). - A tunnel client:
ngrokorcloudflared, with an account and auth token. - The provider’s test signing secret, exported as an environment variable — never the production secret.
curland Python 3.10+ for the verification and replay steps.
Step 1 — Install and authenticate the tunnel
Install the agent and register your auth token once. The token ties the tunnel to your account and, with ngrok, unlocks reserved domains.
# macOS / Linux — ngrok
brew install ngrok # or: download the binary from ngrok.com
ngrok config add-authtoken <YOUR_NGROK_TOKEN>
# Alternative — cloudflared (named, persistent tunnels)
brew install cloudflared
cloudflared tunnel login # opens a browser to authorize a zone
Step 2 — Run the local handler
Start the handler before the tunnel so there is something to forward to. This handler verifies the signature on the raw body against a development secret.
# handler.py
import hmac, hashlib, time, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["WEBHOOK_DEV_SECRET"].encode()
@app.post("/webhook")
async def webhook(request: Request):
raw = await request.body()
header = request.headers.get("x-signature", "")
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
raise HTTPException(status_code=401, detail="malformed header")
if abs(time.time() - ts) > 300:
raise HTTPException(status_code=403, detail="stale timestamp")
expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(status_code=403, detail="bad signature")
print(f"[ok] delivery accepted: {raw[:80]!r}")
return {"status": "accepted"}
export WEBHOOK_DEV_SECRET="provider-test-secret"
uvicorn handler:app --port 8000
Step 3 — Expose the local port
Point the tunnel at port 8000 and copy the public HTTPS URL it prints.
# ngrok — ephemeral URL, fastest to start
ngrok http 8000
# -> Forwarding https://a1b2-203-0-113-9.ngrok-free.app -> http://localhost:8000
# cloudflared — stable named tunnel bound to a DNS record you control
cloudflared tunnel run --url http://localhost:8000 my-dev
Step 4 — Register the URL with the provider
In the provider’s dashboard (or API), add the tunnel URL with your handler’s path appended — for example https://a1b2-203-0-113-9.ngrok-free.app/webhook. Subscribe to the event types you want to receive. With ngrok, also open its local inspector at http://localhost:4040 to see every request and response in real time.
Step 5 — Replay the provider’s test events
Trigger a delivery. Most providers expose a “send test event” button or a CLI. If you only have a captured payload, sign and replay it yourself with curl so you can iterate without the provider:
# replay.sh — sign a captured payload and POST it through the tunnel
BODY='{"type":"order.created","id":"evt_test_1"}'
TS=$(date +%s)
SECRET="provider-test-secret"
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -sS -X POST "https://a1b2-203-0-113-9.ngrok-free.app/webhook" \
-H "content-type: application/json" \
-H "x-signature: t=${TS},v1=${SIG}" \
--data "$BODY"
Step 6 — Verify signatures locally
Confirm the security path actually runs. A correctly signed payload should be accepted; a tampered body must be rejected with 403. This is the assertion that proves your local environment mirrors production.
Verification
Run the same flow as a deterministic check. The signed request returns 200; mutating one byte of the body flips it to 403.
# Positive case — expect: {"status":"accepted"}
bash replay.sh
# Negative case — change the body but keep the old signature, expect HTTP 403
BODY='{"type":"order.created","id":"evt_test_1"}'
TS=$(date +%s)
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "provider-test-secret" | awk '{print $2}')
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"https://a1b2-203-0-113-9.ngrok-free.app/webhook" \
-H "content-type: application/json" \
-H "x-signature: t=${TS},v1=${SIG}" \
--data '{"type":"order.created","id":"TAMPERED"}'
# -> 403
You can also confirm receipt without the provider by watching the handler’s stdout for the [ok] delivery accepted line, or the ngrok inspector at http://localhost:4040 for the request/response pair.
Failure modes and gotchas
- Signature passes with
curlbut fails from the provider. The provider hashes its canonical serialization; if your handler re-parses and re-dumps JSON before hashing, key ordering or whitespace will differ. Hash the raw bytes received, exactly as the handler above does. 502 Bad Gatewayfrom the tunnel. The local app is not listening on the forwarded port. Startuvicornon8000before the tunnel, and confirm the port in both commands matches.- Deliveries stop after lunch. An ephemeral
ngrokURL changed when the session dropped. Re-register the new URL, or switch to acloudflarednamed tunnel or anngrokreserved domain for a stable address. - Replayed event processed twice. Your handler has no idempotency guard, so a second replay of the same event mutates state again. Key processing on the event ID before re-running deliveries.
Related
- Local webhook development with tunnels — the patterns and trade-offs behind these commands.
- Debugging failed webhook deliveries — diagnose and replay failures captured in production.
- Webhook Testing & Local Development — the full testing pipeline.