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

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