docs
v1 Pricing Start free
Concepts / capture hooks

Capture hooks

A public URL that records every HTTP request sent to it, so your suite can assert the app under test actually fired its webhooks — the inbound-HTTP sibling of inboxes and phone numbers.

Two features, two names

mailfixture has two webhook-shaped features and keeps their vocabularies strictly apart. A capture hook (this page, /v1/hooks) is an inbound bin: your app sends requests to us, your test reads them back. A webhook endpoint (webhooks, /v1/webhooks) is outbound: we sign and deliver events about received mail to your infrastructure. If you're asserting "did my app call its webhook?", you want hooks.

Create a hook, point your app at it

POST /v1/hooks mints a hook and returns its ingest_url — a high-entropy address on a dedicated capture host (never the brand or API origin), like https://hook.mxsink.sh/<token>. Configure it as the webhook destination in the app under test, trigger the flow, then long-poll:

the assertion loop
# 1. create
curl -X POST https://api.mailfixture.com/v1/hooks \
  -H "Authorization: Bearer $MAILFIXTURE_KEY"
# → { "id": "…", "ingest_url": "https://hook.mxsink.sh/x7f3…", … }

# 2. app under test fires its webhook at the ingest_url

# 3. assert it arrived (server-held long-poll, no sleeps)
curl "https://api.mailfixture.com/v1/hooks/{id}/requests?wait=45&match=body:order.created" \
  -H "Authorization: Bearer $MAILFIXTURE_KEY"

Both SDKs wrap the loop: mfx.createHook() / mfx.create_hook() returns a handle whose waitForRequest() / wait_for_request() resolves with the captured request or times out. Over MCP the same pair is create_hookwait_for_hook_request. The token in the URL is a write capability — anyone who knows it can add requests to the bin, nobody can read from it — so treat it like any webhook secret and prefer ttl_seconds for throwaway hooks.

What gets captured

Every method — POST, GET, PUT, PATCH, DELETE, even a provider's OPTIONS preflight or challenge GET. Everything after the token in the URL (path and query) is recorded, so …/<token>/orders/7?src=stripe stores path = "/orders/7?src=stripe". The sender always gets the same fixed answer: 200 {"ok":true}, with permissive CORS. Responses are deliberately not configurable — a hook proves your app sent the request; it never plays the remote end's role beyond acknowledging.

Bodies are capped at 256KB (larger requests answer 413). Headers are stored with lowercased names, and duplicate values for the same name keep their order — multi-value headers are exactly what you end up debugging. The relative order of different header names is not preserved (HTTP/2 and proxies don't keep it on the wire either — don't assert on it). The proxy's own hop headers are stripped; the sender's IP is recorded separately as remote_ip.

Reading captures back

GET /v1/hooks/{id}/requests lists summaries (method, path, content type, size) oldest-first with the same wait/since/match semantics as messages. The match grammar is method:POST (exact verb, case-insensitive — substring would make method:P match POST, PUT, and PATCH), path:term and body:term (substring); a bare term matches the body. Body matching searches the first 32 KiB of body bytes, while the detail endpoint still returns the complete body.

GET /v1/hook-requests/{id} returns the full request: headers, and the body as text. When the raw bytes weren't valid UTF-8 text, body is a lossy rendering and body_base64 carries the exact bytes — signature verification (an X-Hub-Signature-256 HMAC, say) needs the bytes as sent, so compute it over body_base64-decoded content when that field is present.

Limits and the 429 contract

Hooks per plan: 1 free, 3 solo, 10 team, 25 scale. Captured requests are hard-capped on every plan — 100/month free, 2,500 solo, 15,000 team, 100,000 scale, with daily caps of 100/2,000/10,000/50,000 — because no overage meter exists for them yet. Past a cap, ingest answers 429 with a Retry-After header rather than silently accepting and dropping: the sender is your own app, and an honest refusal in its delivery logs beats a test suite hanging on a capture that never became visible. Quota isn't the only 429 source: each hook also has a burst limit (bursts to 200 requests, sustained 20/second) so one runaway sender loop can't monopolize ingest — same header, same contract. Retention matches your plan's message retention (3/14/30/30 days); POST /v1/hooks/{id}/clear empties a hook between runs without changing its URL.

Boundaries

Captures are terminal. A captured request is never forwarded, never replayed, and — by design — never fires outbound webhook events: if it did, registering a hook's own ingest URL as a webhook endpoint would loop forever. Capture hooks receive; that's the whole contract, the same receive-only rule the rest of mailfixture lives by.

Next

Messages
The wait/since/match semantics hooks reuse.
Webhooks (outbound)
The other direction: signed event delivery.
API reference
Every hook endpoint and status code.
Was this page useful? yes no Quickstart: Playwright →
ON THIS PAGE
edit this page ↗
report an issue ↗