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

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.

One delivery crossing the tunnel and returning The provider posts a signed request to the tunnel edge, the edge pushes it down the agent's outbound connection, the agent calls localhost port 8000, and the 200 travels back along the same path while the inspector records both directions. Provider Tunnel edge Local agent uvicorn :8000 POST, x-signature set down the open socket raw bytes, unaltered 200 after verifying response frame 200 recorded as delivered inspector at :4040 records both directions
The provider only ever sees the edge, so a timeout it reports is really your handler's latency plus two internet hops — check the inspector before blaming the provider.

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.

Anatomy of the signed webhook request The x-signature header splits into a header name, a unix timestamp checked against the tolerance window, and an HMAC-SHA256 digest recomputed by the handler over the timestamp, a dot and the raw request body. the header the provider sends x-signature: t=1753420800, v1=9f2c4e...ab41 raw request body hashed byte for byte unix seconds when signed must be within 300 s HMAC-SHA256 hex digest recomputed by the handler signed string = timestamp + dot + raw body hmac.new(SECRET, signed, sha256).hexdigest()
Both the timestamp and the digest are derived from the same bytes, so a body your framework re-parsed before you hashed it fails even though the secret is right.

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.

What each hop adds to the request The request from the provider keeps its body and signature intact across all three hops, while the tunnel adds forwarding headers and the local application sees a loopback peer address over plain HTTP. The body survives every hop; almost nothing else does provider to edge edge to agent agent to your app Host: dev.example.com x-signature: t=..., v1=... content-length: 412 HTTP/2 over TLS Host kept unless rewritten x-forwarded-for: provider ip x-forwarded-proto: https HTTP/1.1 down the socket peer address is 127.0.0.1 body bytes unchanged plain HTTP on loopback TLS ended at the edge host checks, ip allowlists and https redirects read the middle column which is why they fail locally and nowhere else
Signature verification is unaffected because the body is byte-identical at every hop; everything that inspects the connection rather than the payload needs configuring.

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.

One scripted development session A session starts the application, brings up the tunnel on a reserved hostname, verifies readiness, runs the work, and ends by deregistering the endpoint so no dead URL is left behind. The step everyone forgets is the last one start the app wait for :8000 start the tunnel reserved hostname probe the URL expect a 401, not a 502 work breakpoints on teardown: deregister the endpoint a dead URL keeps collecting failures Probing for a 401 proves the whole path; probing for a 200 only proves you signed correctly
Probe with a deliberately unsigned request: a 401 from your handler proves every hop end to end, while a 502 tells you the app never came up.
#!/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.

Triage tree for a delivery that never reaches your breakpoint Four observed symptoms — nothing in the inspector, a 502 from the edge, a 403 from the handler, and a 200 that writes twice — each map to one dominant cause and one fix. Local breakpoint never hit no request shown in the inspector edge returns 502 Bad Gateway handler returns 403 on a signed POST 200 OK but the row is written twice tunnel hostname rotated on restart uvicorn not bound to port 8000 body re-serialised or clock skew no idempotency key on the event reserve a domain or name the tunnel start the app before the tunnel hash the raw bytes and check NTP sync upsert keyed on the event id only the third branch is a signing problem; the other three are transport or state
Reading left to right, the first two branches are fixed in your terminal and the last in your database — only the middle one justifies opening the verification code.

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.