GUIDES / WEBHOOK CAPTURE

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.

NAMING This guide tests webhooks your app sends — the capture direction. Consuming MailFixture's own signed email-event webhooks (an email landed, notify my infrastructure) is the opposite direction: the email webhook guide.

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:

the whole loop, in curl
# 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.

fixtures.ts — Playwright
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';
checkout.spec.ts
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:

conftest.py + the test
@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:

verify your app's signature — JS and Python
// 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.

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

SYMPTOM & CAUSEEVIDENCEFIX
Wait times out but the app "sent it" — destination never updated, or the send is behind a flag/queue that didn't runHook's request list empty; app's own delivery log has the truthConfigure the URL from the fixture; assert the trigger before the wait
Signature assertion fails — HMAC computed over the text rendering, or the wrong header casing looked upbody_is_utf8: false / body_base64 present; header lookup returns undefinedCompute over body_bytes / hookRequestBodyBytes; look up lowercased names
match 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 verb
413 on ingest — body over 256KBSender's log shows the 413; nothing capturedSend a reference, not the blob — webhook payloads that big break real consumers too
429 on ingest — daily/monthly hard cap, or the per-hook burst limitRetry-After header; dashboard usage meterFix runaway sender loops; size the plan for suite-runs-per-day
429 with no Retry-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 exists
Tests interfere in parallel — one shared hook across workersWaits resolve with another test's request; failing test varies run to runThe hook-per-test fixture above

What a capture hook doesn't test

Where to go next

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.

1 capture hook + 100 captures/mo free · no tunnels, no listener to babysit
Start free Read the capture-hook docs