Every webhook-driven integration eventually runs into the same wall: your handler lives on localhost:8080, but the events you need come from Stripe, GitHub, or Shopify — services that have no route to your laptop.
The classic response is to reach for a tunnel. You run ngrok http 8080, paste the generated URL into Stripe's dashboard, and start receiving real events. It works immediately, which is why it became the default. But tunnels are a blunt instrument, and they accumulate hidden costs as your team and integration complexity grow.
What Tunnels Are Good For
Tunnels are genuinely useful for first-time integration work: exploring a new provider's event format, watching real events land in real time, or debugging a production issue by temporarily routing traffic to your machine.
A quick comparison of the main options:
| Tool | Free tier | Account required | Stable URL | Works in CI |
|---|---|---|---|---|
| ngrok | Limited | Yes | Paid only | Limited |
| cloudflared | Yes (with setup) | Yes (Cloudflare) | Yes | Yes |
| localtunnel | Yes | No | No | Unreliable |
| smee.io | Yes | No | Yes | Yes |
| Webhook Relay | Paid | Yes | Yes | Yes |
For one-off exploration, any of these works fine. The problems start when you try to make tunnel-based development repeatable across a team.
Where Tunnels Break Down
Flaky URLs. Free ngrok tunnels generate a random subdomain on each restart: abc123.ngrok.io today, def456.ngrok.io tomorrow. Every new session means updating the webhook URL in the provider dashboard, waiting for propagation, and hoping the provider hasn't cached the old URL.
Not CI-friendly. Your CI pipeline doesn't have a persistent public address. You can configure ngrok in CI with a fixed URL on a paid plan, but now you have a billing dependency and a shared tunnel that concurrent CI runs fight over.
Third-party traffic exposure. Every event that passes through a tunnel transits a third-party server. For most events this is fine. For events containing PII — customer email, billing data, personal health information — routing real production events through ngrok's infrastructure raises compliance questions your security team will eventually ask about.
Dev/prod configuration drift. Your tunnel URL isn't the same URL your production system uses. You need a separate webhook configuration in every provider dashboard for local dev, and it's easy for those to drift out of sync with production settings.
A Better Model: Receive Once, Test Many Times
The most reliable approach to local webhook development isn't a live tunnel — it's record and replay.
- ›Use a tunnel (or a staging environment) to receive a real event once
- ›Save the raw request — headers, body, timestamp — to a file
- ›Replay that saved request against
localhostas many times as you need
This gives you deterministic test inputs, no tunnel required for day-to-day work, real payloads instead of mocked ones, and a feedback loop measured in milliseconds.
Capturing a real event
When a webhook arrives in your staging environment, save the full request to disk:
func captureWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
captured := map[string]interface{}{
"method": r.Method,
"headers": r.Header,
"body": string(body),
}
data, _ := json.MarshalIndent(captured, "", " ")
os.WriteFile("testdata/stripe_payment_succeeded.json", data, 0644)
w.WriteHeader(200)
}Commit testdata/stripe_payment_succeeded.json to your repository. Now every developer on your team has the same event to work with — permanently.
Replaying it locally
#!/bin/bash
# scripts/replay-webhook.sh
PAYLOAD=$(cat testdata/stripe_payment_succeeded.json)
BODY=$(echo "$PAYLOAD" | jq -r '.body')
TIMESTAMP=$(date +%s)
SECRET="your-test-signing-secret"
# Compute HMAC-SHA256 in Stripe-compatible format
SIG=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | \
openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -s -X POST http://localhost:8080/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=${TIMESTAMP},v1=${SIG}" \
-d "$BODY"Run this while your handler is running locally. It's repeatable, fast, and requires no external services.
Structuring Your Handler for Testability
Record-replay works best when your webhook handler is thin. Business logic should be injectable and testable independently from the HTTP surface.
// Avoid: business logic entangled with transport
func handleStripeEvent(w http.ResponseWriter, r *http.Request) {
// parse, validate, process, write to DB, send email — all in one place
w.WriteHeader(200)
}
// Prefer: handler is just transport
func handleStripeEvent(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := verifySignature(body, r.Header.Get("Stripe-Signature")); err != nil {
http.Error(w, "invalid signature", 400)
return
}
w.WriteHeader(200) // acknowledge immediately
go processor.Handle(body) // process asynchronously
}With this structure, you can unit-test processor.Handle with raw bytes — no HTTP machinery required. The handler test only needs to verify three things: it validates the signature, returns 200 quickly, and enqueues the event.
Using GetHook Replay in Development
If you're using GetHook as your webhook gateway, the replay feature gives you production-fidelity event replay without a tunnel. Any past event in the log can be replayed to any destination — including a local URL or a staging environment.
# Replay a specific event via the GetHook API
curl -X POST https://api.gethook.to/v1/events/evt_abc123/replay \
-H "Authorization: Bearer hk_..." \
-H "Content-Type: application/json" \
-d '{"destination_id": "dst_local_dev"}'This is especially useful when debugging a production failure: you have the exact payload that failed, with original headers intact, and you can replay it as many times as needed against any version of your handler.
In CI: A Local Test Harness
For CI pipelines, the cleanest approach is a test harness that starts your handler in-process and fires events at it programmatically. No tunnel, no external dependencies.
Here's the pattern in Go using httptest:
func TestStripePaymentSucceeded(t *testing.T) {
handler := NewWebhookHandler(db, emailClient)
server := httptest.NewServer(handler)
defer server.Close()
payload := loadFixture(t, "testdata/stripe_payment_succeeded.json")
timestamp := time.Now().Unix()
sig := computeTestSignature(payload, timestamp, testSigningSecret)
req, _ := http.NewRequest("POST", server.URL+"/webhooks/stripe",
bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Stripe-Signature",
fmt.Sprintf("t=%d,v1=%s", timestamp, sig))
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
// Verify the side effect
order, _ := db.GetOrder(t.Context(), "ord_abc123")
assert.Equal(t, "paid", order.Status)
}loadFixture reads from testdata/ — the same files captured from the real provider. Your CI has real payloads, real signature verification, and real side-effect assertions. No external network required.
What to Commit to Your Repository
A complete local webhook development setup for a team includes:
| File | Purpose |
|---|---|
testdata/<provider>_<event>.json | Captured real event payloads |
scripts/replay-webhook.sh | CLI replay against localhost |
internal/webhook/handler_test.go | Tests using httptest |
.env.test | Non-production test signing secrets |
Makefile target webhook-replay | Single command: replay against running server |
With this in place, any developer can run make webhook-replay and verify that the handler processes a real Stripe event correctly — locally, in seconds, without touching a tunnel or a provider dashboard.
Tunnels are a useful escape hatch, not a development workflow. Capture real events once, replay them deterministically, keep your business logic injectable, and your webhook development will be faster, reproducible, and CI-friendly from day one.
If you're building the webhook infrastructure layer itself — not just the consumer side — GetHook provides event replay, a full delivery log, and a local-dev-friendly API so you can iterate without ever opening an ngrok account.