Back to Blog
developer experiencetestinglocal developmentwebhooks

Developing and Testing Webhooks Locally: Moving Beyond ngrok

Tunnels get you started, but they break in CI, require accounts, and route your dev traffic through third-party servers. Here's a more reliable approach to local webhook development using record-replay, local test harnesses, and injectable handler design.

A
Aleksa Vukovic
Developer Relations
April 1, 2026
8 min read

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:

ToolFree tierAccount requiredStable URLWorks in CI
ngrokLimitedYesPaid onlyLimited
cloudflaredYes (with setup)Yes (Cloudflare)YesYes
localtunnelYesNoNoUnreliable
smee.ioYesNoYesYes
Webhook RelayPaidYesYesYes

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.

  1. Use a tunnel (or a staging environment) to receive a real event once
  2. Save the raw request — headers, body, timestamp — to a file
  3. Replay that saved request against localhost as 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:

go
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

bash
#!/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.

go
// 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.

bash
# 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:

go
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:

FilePurpose
testdata/<provider>_<event>.jsonCaptured real event payloads
scripts/replay-webhook.shCLI replay against localhost
internal/webhook/handler_test.goTests using httptest
.env.testNon-production test signing secrets
Makefile target webhook-replaySingle 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.

Get started free →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.