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
Once the agent connects, a delivery crosses four hops and comes back the same way. Nothing is listening on an inbound port on your machine: the agent opened the connection outbound, and the edge multiplexes each request down that existing socket. That is why the tunnel works behind NAT, and why the round-trip latency you see locally is roughly two internet hops rather than one.
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"
Every character of that header matters, and the two failure modes people hit here are hashing the wrong string and hashing the wrong bytes. The signed string is the timestamp, a literal dot, then the body exactly as it will travel — not a re-serialised copy, and not the body alone.
What the tunnel changes about the request your handler sees
The tunnel is transparent about the body — that is the whole reason signature verification works across it — but it is not transparent about everything else, and the differences are concentrated in exactly the places webhook handlers make assumptions. Knowing them turns three baffling local-only failures into one-line fixes.
The Host header is the first trap. The agent forwards the public hostname, so a framework with a strict allowed-hosts list — Django’s ALLOWED_HOSTS, or any virtual-host router — answers 400 Bad Request before your view runs, and the message mentions an invalid host rather than anything webhook-shaped. Either add the tunnel hostname to the allowed list or start the agent with host-header rewriting so the app sees localhost:8000. The second trap is the client address: the TCP peer is the agent on loopback, so any code that allowlists provider IP ranges will see 127.0.0.1 and either reject everything or, worse, accept everything. The real client address arrives in x-forwarded-for, and it is only safe to trust that header because the tunnel is the sole ingress — never carry that assumption into production unchanged. Third, x-forwarded-proto is https while the local connection is plain HTTP, so a framework configured to redirect insecure requests will bounce the delivery with a 301 unless it is told to trust the forwarded protocol.
One more surprise catches people during endpoint registration rather than delivery. Free tunnel tiers interpose an HTML interstitial on browser-style requests, and providers that validate a new endpoint with a plain GET, a HEAD, or a challenge they expect echoed back will receive that page instead of your response. The observable symptom is a provider that refuses to save the URL with a message about an unexpected response body, while curl -X POST against the same URL works perfectly. Send the documented skip header, or use a named tunnel on a domain you control, where no interstitial exists.
Making the setup survive a restart
Typing four commands in three terminals is fine on day one and corrosive by day three, because every restart re-runs the registration step and every missed registration produces deliveries that fail for a reason that has nothing to do with your code. Collapse the whole thing into one script that starts the app, brings up a stable hostname, and refuses to continue if either half is not actually serving.
#!/usr/bin/env bash
# dev.sh — one command for the whole local webhook loop
set -euo pipefail
PORT=8000
DOMAIN="dev-ana.example.com" # an ngrok reserved domain you own
export WEBHOOK_DEV_SECRET="provider-test-secret"
cleanup() { kill "${APP_PID:-0}" "${TUNNEL_PID:-0}" 2>/dev/null || true; }
trap cleanup EXIT
uvicorn handler:app --port "$PORT" &
APP_PID=$!
# Wait for the app before exposing it, so the tunnel never serves a 502.
# Any HTTP status counts as ready; we only need the socket to answer.
for _ in $(seq 1 20); do
curl -s -o /dev/null "http://localhost:${PORT}/webhook" && break
sleep 0.5
done
ngrok http "$PORT" --domain "$DOMAIN" --log stdout &
TUNNEL_PID=$!
sleep 2
# An unsigned POST must come back 401: that proves every hop is live.
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
"https://${DOMAIN}/webhook" -H "content-type: application/json" --data '{}')
if [ "$CODE" != "401" ]; then
echo "[fail] expected 401 from the handler, got ${CODE}" >&2
exit 1
fi
echo "[ok] https://${DOMAIN}/webhook is live and rejecting unsigned requests"
wait "$APP_PID"
A reserved domain makes this idempotent: the URL registered with the provider never changes, so the script has nothing to re-register and nothing to clean up. If you are stuck on ephemeral hostnames, add a step that reads the current URL from the agent’s local API and pushes it to the provider, and — more importantly — a teardown step that removes the registration. A dead registration keeps accumulating failed deliveries, and providers commonly disable an endpoint after a run of them, which takes the whole team’s test integration down with it.
Verification and testing
Confirm the security path actually runs rather than assuming it. Run the same flow as a deterministic check: the signed request returns 200, and 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.
Once both cases pass by hand, move them into the test suite so they keep passing without you. Lift the same signing logic into a mock webhook server for integration tests that posts over loopback instead of the tunnel, and pin the payload shape with consumer-driven contract tests for webhooks so a provider’s rename fails a build rather than a breakpoint.
Failure modes and gotchas
Four symptoms cover almost every failed local delivery, and each has one dominant cause. Work down the tree below before you touch the signing code — three of the four are transport or configuration problems, and changing the HMAC logic to chase them only introduces a second bug.
- 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.
Frequently Asked Questions
The inspector shows the request but my handler never logged it — where did it go?
Look at the status the inspector recorded for that request. A 502 means the agent could not reach your port at all, a 400 usually means the framework rejected the forwarded Host header before routing, and a 404 means the path registered with the provider does not match the route you defined. All three are visible in the inspector's response pane, which is faster than adding logging to a handler that was never called.
Can I keep using the same tunnel URL after rebooting my machine?
Only with a reserved domain or a named tunnel; a plain ngrok http 8000 allocates a fresh random hostname every session. The reservation costs a few minutes to set up once and removes the entire class of "deliveries stopped and nobody changed anything" problems. If you are evaluating whether it is worth it, count how many times you have pasted a new URL into a provider dashboard this week.
Why does openssl produce a different digest than my Python handler?
Nine times out of ten the shell added a trailing newline. echo appends one by default, so use printf as the replay script above does, and be careful that command substitution does not strip or add whitespace. Compare the exact byte length of the signed string on both sides before suspecting the algorithm — the hash is almost never wrong, the input almost always is.
Is it safe to commit the replay script with the test secret in it?
No. Even a test secret is a credential that lets anyone reach your handler with valid signatures, and secrets have a habit of being promoted to real ones later. Read it from the environment in the script, keep the value in a git-ignored dotenv file, and commit an example file listing the variable names so a new teammate knows what to set.
How do I test what happens when my handler is slow, without waiting?
Add a development-only delay controlled by an environment variable or query flag, set it just past the provider's timeout, and watch the delivery get marked failed and retried. That single experiment teaches you more about your integration than any amount of reading, because it shows you the duplicate arriving and proves whether your idempotency guard catches it.
Do I still need the tunnel once the integration works?
Not for the regression suite, and that is the goal. Once a signed request and a tampered request both behave correctly by hand, move those two cases into automated tests that post over loopback with no external dependency. Keep the tunnel for exploring a provider's real payloads and for the first delivery of a brand new event type, and let the test suite carry everything you have already learned.
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.
- Building a mock webhook server for integration tests — the same deliveries without a tunnel.
- Webhook Testing & Local Development — the full testing pipeline.