Platform integration

Agent infrastructure

Webhook test: how to test webhooks end to end without breaking production

Webhook test guide in seven steps: tunnel, curl, signatures, idempotency, retries, testing tools, and staging — with the pitfalls that catch most teams.

6 minute read
Decorative imagery showcasing Pontil's brand

By the end of this guide you can test a webhook from three angles: local delivery against your dev machine, signature and payload correctness against a mock producer, and behaviour against the real producer in a staging environment. You need a webhook receiver you can run locally, one signing secret, and about 45 minutes. This walks through seven steps and closes with the pitfalls that catch most teams.

A webhook test isn't one thing. It's a stack of checks — reachability, signature verification, idempotency, retry behaviour, and payload shape — that each fail in a different way. Test them separately.

Step 1 — Expose your local receiver with a tunnel

Before you can accept a real webhook from a third-party producer, they need a public URL. Local ports don't count. Use a reverse tunnel to forward a public HTTPS URL to your development port.

Run ngrok, Cloudflare Tunnel, or a similar tool:

ngrok http 3000

Expected output includes a forwarding URL like https://abc123.ngrok-free.app -> http://localhost:3000. Register that URL as the webhook endpoint in the producer's dashboard. (Note: ngrok now requires an authenticated account — run ngrok config add-authtoken <token> once after signing up before the tunnel command works.)

Why this matters: most webhook producers require HTTPS and won't retry against a URL that returned a TLS error. The tunnel handles the certificate for you.

Step 2 — Send a synthetic payload with curl

Before involving the real producer, send a hand-crafted request to prove the receiver parses the body and returns the right status code. This isolates receiver bugs from delivery bugs.

curl -X POST https://abc123.ngrok-free.app/webhooks/orders \
 -H "Content-Type: application/json" \
 -H "X-Signature: t=1700000000,v1=placeholder" \
 -d '{"event":"order.created","id":"evt_test_001","data":{"order_id":"ord_123"}}'

Header names and signature formats vary by producer — the t=<ts>,v1=<hex> shape above follows Stripe's convention (delivered as Stripe-Signature), while GitHub uses X-Hub-Signature-256 and Shopify uses X-Shopify-Hmac-Sha256. Match whatever your producer sends.

Expected: HTTP 200 (or 202 if you queue the work) within a few hundred milliseconds. If you get a 500, fix that before touching signatures. If you get a 401, your verification is running — good, move on.

One verb per step: this step tests the parser only. Signatures come next.

Step 3 — Test signature verification with known vectors

Webhook signatures are where most receivers quietly break. Test them in isolation with fixtures: a payload, a secret, and the expected signature.

Generate a valid HMAC-SHA256 signature in a scratch script:

import hmac, hashlib, time

secret = b"whsec_test_1234567890"
timestamp = str(int(time.time()))
payload = b'{"event":"order.created","id":"evt_test_002"}'

signed = f"{timestamp}.".encode() + payload
signature = hmac.new(secret, signed, hashlib.sha256).hexdigest()

print(f"t={timestamp},v1={signature}")

Then replay that payload against your receiver with the header. Expected: 200. Now flip one character in the signature and replay. Expected: 401. Now use a timestamp from an hour ago. Expected: 401 (replay window rejected).

Run all three cases in CI. Signature bugs are silent — a receiver that accepts everything looks healthy in production until the day someone forges an event. For the mechanics, see the webhook signature verification guide.

Step 4 — Verify idempotent processing under duplicate delivery

Webhook producers retry on any non-2xx response or timeout, and you can still see duplicates after a 2xx — lost network acks, handler crashes before the response is sent, or someone hitting resend in a dashboard. Your receiver will see duplicate events either way. Test that it processes each event exactly once.

Send the same signed payload twice:

# First delivery
curl -X POST $URL -H "X-Signature: $SIG" -d "$BODY"
# 200 OK, one row inserted

# Second delivery, same event id
curl -X POST $URL -H "X-Signature: $SIG" -d "$BODY"
# 200 OK, zero new rows

Check your database. If the second call created a duplicate row, your idempotency key handling is broken. The standard fix is to store the event id in a unique-constrained table and swallow the conflict. The idempotency key guide walks through the transactional pattern.

Write this test once and keep it in CI. It's the check that stops silent double-charges.

Step 5 — Simulate retries and backoff

Producers retry when your receiver returns 5xx or times out. Your receiver has to survive that traffic pattern. Simulate it.

Make your handler return 500 for the first three attempts, then 200. Send a webhook and watch the producer's retry log. You should see exponential spacing — typically doubling — and eventual success. Common patterns are covered in the exponential backoff guide.

What to check:

  • Retries actually arrive. Retry policies vary wildly — GitHub doesn't auto-retry at all, Shopify caps at 8 attempts over 4 hours and can then remove the subscription, Stripe retries for up to three days. Read the producer's docs.
  • The event id stays the same across retries. If it doesn't, your idempotency test in step 4 won't protect you.
  • Your receiver doesn't hold the connection open. Return 200 fast and process asynchronously — the queue-first pattern explains why.

Step 6 — Reach for the right webhook testing tool

At this point curl covers most of what you need. But there are situations where a dedicated tool saves real time:

Situation
Reach for

Inspecting raw payloads a producer sends

Webhook.site or RequestBin — captures every request without a receiver

Replaying failed deliveries

The producer's dashboard where supported (Stripe and GitHub both offer manual resend; Shopify does not — you'll need your own DLQ or a proxy like Hookdeck)

Load-testing your receiver

k6 or hey against a signed payload fixture

Local debugging with breakpoints

Your IDE, plus a tunnel from step 1

CI regression on signatures

Your test framework with fixtures from step 3


Don't reach for a hosted webhook testing tool for signature or idempotency correctness. Those belong in code you own, in CI.

Step 7 — Run the real producer against staging

The last test is the one that finds everything the fixtures missed. Point the real producer at a staging environment and trigger a real event — create an order, push a commit, whatever fires the webhook you care about.

Watch three things:

  1. Payload shape. Compare the real payload byte-for-byte against your fixture. Producers add fields without notice. If your parser is strict, this will surface it.
  2. Header set. Real requests carry headers your fixtures didn't — User-Agent, X-Request-Id, sometimes signature versions you haven't seen. Log the full header set on the first hit and diff it against what you expected.
  3. Timing. Real events arrive when the source system decides, not when you're watching. Leave staging running for a day and check the logs. You'll catch the events that only fire on obscure conditions.

This step also catches the class of bug where the producer's docs and its actual behaviour don't match. That happens more often than anyone likes to admit.

Common pitfalls

  • Testing against JSON that's been re-serialised. The signature is computed over the raw bytes. If your framework parses the body before you verify, the bytes are gone. Read the raw body first, verify, then parse.
  • Trusting a 200 in the producer's dashboard. The producer marks a delivery successful when your endpoint returns 2xx. That says nothing about whether you processed the event correctly. Assert on your own database, not on their dashboard.
  • Skipping replay-window checks. A signature that's valid forever is a signature an attacker can replay. Reject anything older than five minutes.
  • Only testing the happy path. The interesting bugs are in the failure modes: partial writes, timeouts mid-processing, retries during a deploy. Test those explicitly.
  • Forgetting webhook events have a schema. When the producer adds a field, your parser shouldn't crash. Test with an extra unknown field in the payload and confirm your receiver ignores it cleanly.

Webhooks look simple until you start testing them properly. Do the seven steps once, keep the fixtures in CI, and the class of bug that wakes people up at 3am mostly goes away.

Join our weekly newsletter

Stay up to date on the ever changing agentic landscape.

POSTS

Related content

Platform integration

API strategy

Webhook signature verification: a practical HMAC-SHA256 guide

7 minute read

Platform integration

API strategy

Webhook idempotency key: a practical guide to exactly-once processing

7 minute read

Platform integration

Agent infrastructure

Webhook retry with exponential backoff: a practical implementation guide

7 minute read