Webhook testing: prove your app actually sent it.
Your app promises to fire a webhook when an order completes, a user signs up, a job finishes. The test for that promise is not "the mock was called" — it's a real HTTP request, captured off the wire, with the method, path, headers, and exact body bytes your integration partners will receive.
The short answer. Testing outbound webhooks needs a real receiver your suite can read back programmatically. Create a capture hook — a public URL that records every request sent to it — point the app under test at it, trigger the flow, then long-poll the captured request back and assert on it: method, path, headers, parsed JSON, and the signature computed over the exact body bytes. One hook per test isolates parallel runs the same way one inbox per test isolates email.
How teams improvise it, and where each one breaks
Mocking the HTTP client. expect(fetch).toHaveBeenCalledWith(…) proves your code called a function. It proves nothing about what leaves the process: serialization, content-type headers, the signature computation, middleware that mutates the payload, the retry wrapper that double-fires, the proxy config that rewrites the URL. Webhook bugs live almost entirely in that untested gap — the mock asserts your intent, not your wire format.
A local server plus a tunnel. An Express listener in the test process, ngrok in front of it so the deployed app can reach it. It can work, and it flakes constantly: tunnel startup races the test, CI runners hit tunnel auth and rate limits, parallel shards collide on ports, and the receiver itself becomes infrastructure you maintain but never test. The suite now depends on a third-party tunnel being up to test your own feature.
A manual bin site. Paste-a-URL webhook viewers are built for eyeballs, not assertions: a browser tab shows you the request, but the test can't wait for it, read it back, or assert on it without scraping. Shared URLs also mean anyone who has seen the URL can read your payloads — fine for a quick look, wrong for a suite that runs with real-ish data.
The structural fix is the same one email testing landed on: a hosted receiver with an API on the read side. The app under test sends to a real public URL; the test reads the capture back over an authenticated API with a server-held wait.
The loop: create, point, wait, assert
Three calls, no infrastructure:
# 1. create a hook curl -X POST https://api.mailfixture.com/v1/hooks \ -H "Authorization: Bearer $MAILFIXTURE_KEY" # → { "id": "…", "ingest_url": "https://hook.mxsink.sh/x7f3…", … } # 2. configure ingest_url as the webhook destination in the app under test, # then trigger the flow that should fire it # 3. long-poll the capture back (held open until it arrives, up to 45s) curl "https://api.mailfixture.com/v1/hooks/{id}/requests?wait=45&match=body:order.created" \ -H "Authorization: Bearer $MAILFIXTURE_KEY"
The ingest_url lives on a dedicated capture host and answers every request with a fixed 200 {"ok":true} — any method, provider challenge GETs included. Everything after the token is recorded: …/x7f3…/orders/42?src=app captures path = "/orders/42?src=app", so one hook can stand in for a whole endpoint namespace.
The wait is the same server-held long-poll the email side uses: the request is held open until a matching capture arrives, so there is no polling loop and no sleep. The match grammar is webhook-shaped: method:POST (exact verb, case-insensitive), path: and body: (substrings; a bare term matches the body).
In a suite: one hook per test
Hooks isolate exactly like inboxes: mint in a fixture, delete in teardown, and parallel workers can never read each other's captures. If your app's webhook destination is configurable per run (an env var, a settings API, a tenant config), point it at the fixture's URL.
import { test as base } from '@playwright/test'; import { MailFixture, Hook } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY export const test = base.extend<{ hook: Hook }>({ hook: async ({}, use) => { const hook = await mail.createHook({ ttlSeconds: 900 }); await use(hook); await hook.delete(); // teardown runs pass or fail; TTL covers killed runs }, }); export { expect } from '@playwright/test';
import { test, expect } from './fixtures'; test('completed checkout fires order.created', async ({ page, hook }) => { await setWebhookDestination(hook.ingestUrl); // your app's config seam await page.goto('/checkout'); await page.click('text=Place order'); const req = await hook.waitForRequest({ match: 'body:order.created', timeout: 30_000, }); expect(req.method).toBe('POST'); expect(req.contentType).toContain('application/json'); const event = JSON.parse(req.body); expect(event.type).toBe('order.created'); expect(event.data.total).toBe(4200); });
The same shape in pytest — a function-scoped fixture, and note the units: the Python SDK counts timeouts in seconds, the JS SDK in milliseconds:
@pytest.fixture(scope="session") def mail(): return MailFixture() @pytest.fixture def hook(mail): hook = mail.create_hook(ttl_seconds=900) yield hook hook.delete() def test_checkout_fires_webhook(hook, app): app.set_webhook_destination(hook.ingest_url) app.complete_checkout() req = hook.wait_for_request(match="body:order.created", timeout=30) assert req.method == "POST" event = json.loads(req.body) assert event["type"] == "order.created"
Asserting signatures: exact bytes, not text
If your app signs its webhooks — an X-Hub-Signature-256 HMAC, a Stripe-style header — the test should recompute the signature, because the signature is precisely the part a mocked client never exercises. Signatures are computed over the body bytes as sent, never over a text rendering, and the capture keeps both: body is text for reading and JSON-parsing; when the raw bytes can't round-trip as text (invalid UTF-8, or a NUL byte anywhere), body_base64 carries them exactly. Both SDKs wrap the decision:
// JS: hookRequestBodyBytes picks body_base64 when present, else encodes body import { hookRequestBodyBytes } from 'mailfixture'; import { createHmac } from 'node:crypto'; const sig = req.headers.find((h) => h.name === 'x-hub-signature-256')?.value; const expected = 'sha256=' + createHmac('sha256', process.env.APP_WEBHOOK_SECRET!) .update(hookRequestBodyBytes(req)).digest('hex'); expect(sig).toBe(expected); # Python: req.body_bytes is the same guarantee digest = hmac.new(secret, req.body_bytes, hashlib.sha256).hexdigest() assert sig == f"sha256={digest}"
Two capture facts matter for header assertions. Names arrive lowercased — look up x-hub-signature-256, not X-Hub-Signature-256. And duplicate values of one name keep their order, but the relative order of different header names is not preserved (HTTP/2 and proxies don't keep it on the wire either) — assert on presence and values, never on position.
Parallel runs, reruns, and cleanup
The parallel-testing rules transfer verbatim, because a shared hook is the same bug as a shared inbox: worker 3's order.created resolves worker 1's wait, and the failing test isn't the guilty one. Per-test hooks end the race; retries rebuild the fixture and start with an empty capture list.
- Delete in teardown, TTL as backstop.
hook.delete()drops the hook and its captures immediately and the URL 404s afterwards;ttl_secondscovers runs CI killed before teardown. clear()is the rerun tool, not teardown. It empties the capture list but keeps the URL — for the one legitimate long-lived-hook case: an app whose webhook destination is painful to reconfigure per test. Clear between runs, and usematchfilters to discriminate within one.- Creation shares the inbox velocity budget. Hook creation draws from the same per-key rate bucket as inbox and phone-number creation (bursts of 10, refilling 10/minute) — a suite minting an inbox and a hook per test spends the budget twice as fast.
- Concurrent hooks are plan-capped: 1 on free, 3 solo, 10 team, 25 scale. With delete-on-teardown the live count is roughly your worker count; the free plan's single hook means parallel suites want a paid tier or the clear-between-runs pattern.
Limits worth knowing before they find you
Bodies are capped at 256KB — larger requests answer 413 and store nothing. Body match scans the first 32 KiB of the body (the detail endpoint still returns every byte) — if your discriminating field sits past that in a huge payload, match on path: instead. Captured requests are hard-capped per plan — 100/month on free up to 100,000 on scale, with daily caps — and past a cap ingest answers 429 with Retry-After rather than silently dropping: an honest refusal in your app's delivery logs beats a suite hanging on a capture that never became visible. A per-hook burst limit (bursts to 200, sustained 20/second) answers the same way when a sender loop runs away. Two smaller ones: browser-origin senders' CORS preflight OPTIONS requests are captured as real rows and count against quota, and captures expire with your plan's retention (3/14/30 days), same as messages.
Failure modes
body_is_utf8: false / body_base64 present; header lookup returns undefinedCompute over body_bytes / hookRequestBodyBytes; look up lowercased namesmatch never matches — discriminator past the 32 KiB scan window, or method: given a substringRequest captured (visible unfiltered) but the filtered wait times outMatch on path: or an early body field; method: takes the exact verbRetry-After header; dashboard usage meterFix runaway sender loops; size the plan for suite-runs-per-dayRetry-After — a deleted or mistyped hook URL: unknown-token requests answer 404, and repeated misses from one IP trip a probe limiter404s in the sender's log first, then bare 429sUpdate the destination — the sender is pointing at a hook that no longer existsWhat a capture hook doesn't test
- Your sender's retry logic. The hook always answers
200 {"ok":true}— responses are deliberately not configurable, so you cannot make it fail to exercise your app's retry/backoff path. Failure injection needs a stub you control; the hook tests the happy-path wire format, which is where the serialization and signature bugs live. - The real consumer's acceptance. A capture proves what your app sent, not that Stripe, Slack, or your customer's endpoint would have accepted it. Contract details beyond the wire shape — auth handshakes, field validation — belong to integration tests against the real (or vendor-sandboxed) endpoint.
- Nothing is forwarded or replayed. Captures are terminal by design — a hook receives and records, full stop. That's the same receive-only rule the rest of MailFixture lives by, and it's why a hook is safe to leave configured: it can never re-emit what it saw.
- The token is a write capability. Anyone holding the ingest URL can append junk requests to the bin (nobody can read from it without your API key). Treat the URL like any webhook secret, and prefer short TTLs for throwaway hooks.
Where to go next
- The reference for everything this guide used — capture semantics, the match grammar, caps and the 429 contract: capture hooks concepts.
- The opposite direction — building and testing a receiver for MailFixture's own signed email-event webhooks: the email webhook guide.
- The isolation architecture these fixtures inherit, with the cost arithmetic: parallel email testing.
If your webhook tests today are a mocked client and hope, the fixture above is the whole migration: one hook per test, one bounded wait, and assertions against the bytes your integrations actually receive.