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

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.
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.
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.
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.
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.
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:
At this point curl covers most of what you need. But there are situations where a dedicated tool saves real time:
Don't reach for a hosted webhook testing tool for signature or idempotency correctness. Those belong in code you own, in CI.
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:
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.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.
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.
Stay up to date on the ever changing agentic landscape.