Email webhook testing: signed events, honest retries, and when to long-poll instead.
Your test suite shouldn't consume webhooks — and your glue code shouldn't poll. This guide covers both halves of that sentence: why a wait= long-poll is the right primitive inside a test, and how to build a webhook receiver for everything around the tests — verifying signatures, deduplicating retries, surviving out-of-order delivery, and fetching message content the one place it actually lives.
The short answer. Inside a test, long-poll the inbox — one authenticated request that returns when the email arrives, no public endpoint required. Use webhooks for the infrastructure around tests: Slack notifications, pipeline triggers, feeding a dashboard. When you do consume them, verify the HMAC signature on the raw body, reject stale timestamps, deduplicate on the delivery id, and never expect the message body in the payload — fetch it by id over the authenticated API.
The failure mode: a receiver written like it's a function call
Webhook consumers fail in a specific, predictable order, and every failure looks fine in the demo:
- Day one: the receiver parses JSON and does the work inline. Works instantly.
- Week two: someone replays a captured request — or the sender retries a delivery your receiver already processed because your 200 arrived a millisecond after their timeout — and the downstream job runs twice. A duplicate Slack ping if you're lucky; a duplicate pipeline deploy if you're not.
- Month two: the receiver deploys with a bug, answers 500 for an hour, and either loses that hour of events forever (no retries on the sender side) or gets hammered when the retry queue drains all at once.
- Somewhere in between: an event arrives out of order — a retried delivery from twenty minutes ago lands after a fresh one — and the consumer that assumed chronological arrival now shows stale state as current.
None of these are exotic. They're the standard physics of at-least-once HTTP delivery, and every serious webhook producer — Stripe, GitHub, and yes, MailFixture — designs its sending side assuming your receiver will hit all of them. This guide is the receiving-side half of that contract, using MailFixture's outbound webhooks as the concrete example.
First decision: do you need a webhook at all?
For tests, no. A test that asserts "the signup email arrived" should long-poll:
GET /v1/inboxes/{id}/messages?wait=45
That request is held open until a matching message arrives or the wait expires. One authenticated call, no public endpoint, no tunnel from CI, no signature verification — and the SDK wait helpers (waitForMessage, wait_for_otp, …) are built on it. A webhook-driven test needs an HTTPS endpoint reachable from the public internet — which a CI runner does not have — plus all the machinery below, to reimplement what one wait= request already does.
Webhooks earn their keep for the glue around tests: pinging a Slack channel when a domain finishes DNS verification, kicking a downstream job when a message lands in a monitored inbox, feeding received-message metadata into a metrics pipeline or audit trail. These are long-lived consumers with real servers, where push beats a poll loop that runs forever. That's the shape MailFixture's webhooks are built for. Three event types exist today: message.received (an email landed — summary fields plus extraction results), sms.received (same, for inbound SMS), and domain.verified (a custom domain finished DNS verification). Webhooks are a paid-plan feature: 3 endpoints on Solo, 10 on Team, 25 on Scale.
What a delivery looks like
Every delivery is a POST to your endpoint with this envelope:
{
"id": "6b9f42e0-…",
"type": "message.received",
"created_at": "2026-07-07T14:18:02Z",
"data": {
"message": {
"id": "0197f3…",
"inbox_id": "0197f2…",
"received_at": "2026-07-07T14:18:01Z",
"from_addr": "noreply@yourapp.example",
"to_addrs": ["x7f3@acme.example"],
"subject": "Your verification code"
},
"extracted": { "otp": { "best": "482913" }, "links": [ … ] }
}
}
id is the delivery's UUID — it stays the same across every retry of that delivery, which makes it your idempotency key. It also arrives as the X-MailFixture-Delivery header, and the event type rides in X-MailFixture-Event, so a router can dispatch without parsing the body. There is no per-endpoint event filter — every endpoint receives every event type — so switch on type and ignore types you don't handle (including ones that don't exist yet).
Notice what's not there: no text_body, no html_body, no SMS text. Bodies never ride in webhooks — the payload carries subjects, senders, and extracted results (OTPs, classified links, auth verdicts), and stops there. This is deliberate, and it's worth adopting the reasoning even for your own webhooks: your email is data you'd rather not have copied into every receiver's request logs, proxy buffers, and error-tracker breadcrumbs along the delivery path. A notification needs to say what happened, not carry the entire artifact.
When a consumer genuinely needs the content, the pattern is notify, then fetch: take data.message.id from the payload and request the message over the authenticated API —
GET https://api.mailfixture.com/v1/messages/{id}
Authorization: Bearer mfx_…
— or client.getMessage(id) / client.get_message(id) in the SDKs. The fetch happens over a channel you authenticate with your API key, from code you control, instead of the body being sprayed at whatever URL is registered. The extra round trip is the price of not turning your webhook receiver into a copy of your mail.
Verify every delivery
Each endpoint gets a whsec_… signing secret when it's created — shown once, in that response only, so store it like the key material it is (an environment variable or secret manager, not the repo). Every delivery carries:
X-MailFixture-Signature: t=1751897882,v1=5257a869e7…
where v1 is HMAC-SHA256(secret, "<t>.<body>") in hex. This is deliberately the same scheme Stripe uses, so if you already verify Stripe webhooks, the same code works with a different header name. Two rules matter in practice:
- Verify against the raw request bytes. Parse-then-reserialize breaks the HMAC on key order or whitespace. Configure your framework to hand you the unparsed body.
- Reject stale timestamps. The signature covers
t, so an attacker can't forge a fresh one — but a captured request replays perfectly forever unless you bound the timestamp's age. A few minutes of tolerance is plenty.
Both SDKs ship a verification helper that does the parse, the timestamp check, and a constant-time comparison:
// JS — default tolerance 300s import { verifyWebhookSignature } from 'mailfixture'; verifyWebhookSignature(rawBody, signatureHeader, secret); # Python — tolerance=300.0 from mailfixture import verify_webhook_signature verify_webhook_signature(raw_body_bytes, signature_header, secret)
A receiver that survives contact with reality
Here's the full shape in Node (Express) — raw body, verify, durably enqueue once, then acknowledge:
import express from 'express'; import { verifyWebhookSignature } from 'mailfixture'; const app = express(); app.post( '/hooks/mailfixture', express.raw({ type: 'application/json' }), // raw bytes — never pre-parsed async (req, res) => { const sig = req.header('X-MailFixture-Signature') ?? ''; if (!verifyWebhookSignature(req.body, sig, process.env.MFX_WEBHOOK_SECRET!)) { return res.status(400).send('bad signature'); } const event = JSON.parse(req.body.toString('utf8')); // One durable operation: insert a job keyed UNIQUE(event.id). // Existing key = duplicate already accepted; either result is success. await enqueueOnce(event.id, event); return res.sendStatus(200); } );
And the same skeleton in Python (Flask):
import os from flask import Flask, request from mailfixture import verify_webhook_signature app = Flask(__name__) @app.post("/hooks/mailfixture") def mailfixture_hook(): sig = request.headers.get("X-MailFixture-Signature", "") if not verify_webhook_signature(request.get_data(), sig, os.environ["MFX_WEBHOOK_SECRET"]): return "bad signature", 400 event = request.get_json(force=True) enqueue_once(event["id"], event) # durable queue insert; id has UNIQUE constraint return "", 200
The load-bearing decisions, spelled out:
- Ack with any 2xx after durable handoff, not after the slow work. Deliveries time out after 5 seconds and follow no redirects. Verify, parse, and put the event in a durable queue inside that window; then acknowledge. Returning 200 before the enqueue is durable creates a crash window that loses the event forever. Doing the downstream work before returning creates duplicates whenever it runs long.
- Dedupe on
event.id, not on message content. The deliveryidis stable across retries of that delivery by design. Make it a unique key on the durable job row: the first request inserts, duplicate retries find the existing row, and both return 200. Keep completed keys comfortably past the retry window (a day covers it). - Treat a duplicate as success (200), not an error. Answering 4xx/5xx to a retry of something you already did just schedules more retries.
- The endpoint must be HTTPS on a publicly routable address. Plain-HTTP and internal/private addresses are rejected at registration — the sender refuses to be a tool for probing your internal network, so don't plan an architecture around
http://10.0.0.5/hook.
Retries, backoff, and the dead-endpoint problem
Anything other than a 2xx — including timeouts and connection failures — is retried: up to 8 attempts at roughly 30s, 2m, 10m, 30m, 2h, 6h, and 12h after the first, about 21 hours of trying in total. That window is your deploy-a-fix budget: a receiver that's down for an evening loses nothing, because the queue re-delivers when it's healthy again.
The flip side: if 5 deliveries in a row exhaust all 8 attempts, the endpoint is auto-disabled, and its still-pending deliveries are marked skipped rather than left to pile up. This isn't punitive — it's what keeps a receiver that's been dead for a week from accumulating an infinite retry backlog. Fix the receiver, then re-enable with POST /v1/webhooks/{id}/enable (or from the dashboard). Listing endpoints shows consecutive_failures, disabled_at, and disabled_reason, so a monitoring check against GET /v1/webhooks can page you before the auto-disable, not after.
Ordering is not guaranteed — design for it. Retries make ordered delivery impossible for any webhook producer being honest with you: if delivery A fails and retries in 10 minutes while delivery B succeeds now, B arrives first. Never build a consumer that assumes arrival order equals event order. Order by the payload's timestamps (created_at on the envelope, received_at on the message) and make handlers idempotent enough that a late-arriving old event can't overwrite newer state.
Testing the receiver itself
The fastest debugging loop is the test event plus the delivery history, side by side:
const client = new MailFixture(); // reads MAILFIXTURE_API_KEY const endpoint = await client.createWebhook( 'https://glue.example.com/hooks/mailfixture', 'slack notifier' ); console.log(endpoint.secret); // whsec_… — shown once, store it NOW await client.testWebhook(endpoint.id); // fires a signed endpoint.test event const deliveries = await client.listWebhookDeliveries(endpoint.id); // [{ eventType: "endpoint.test", status: "succeeded", attempts: 1, // lastStatusCode: 200, … }]
(Python mirrors it: create_webhook, test_webhook, list_webhook_deliveries.) The test event goes through the same signing, delivery, and retry machinery as a real one, so a green endpoint.test in the history means the wiring — TLS, routing, raw-body handling, signature verification — actually works. Send, read the recorded response code, fix, repeat. Every endpoint keeps 7 days of delivery history — one row per delivery, with its attempt count, last status code, and last error — in the dashboard and at GET /v1/webhooks/{id}/deliveries.
Failure modes
last_status_code: 400Feed the framework's raw body (express.raw, request.get_data()) into the verify helpersecret_hint)Fails for one endpoint, works for anotherThe full secret exists only in the creation response — recreate the endpoint and store whsec_… properlyevent.idMake event.id unique in the durable queue; ack after enqueue, work asynchronouslydisabled_at/disabled_reason set on GET /v1/webhooksFix the receiver, POST /v1/webhooks/{id}/enable, monitor consecutive_failuresPOST /v1/webhooksWebhooks require HTTPS on a publicly routable host — use a public receiver, not an internal servicereceived_at, not arrivalGET /v1/messages/{id} with your API key when content is neededWhat this doesn't do
- Webhooks are not the test primitive. If you're wiring a webhook so a test can learn an email arrived, stop — long-polling does that in one authenticated request with none of this machinery. Webhooks are for standing infrastructure.
- Payloads never contain bodies. Not email text, not HTML, not SMS text — by construction, not configuration. The notify-then-fetch round trip is intentional.
- Delivery is at-least-once, unordered. Exactly-once, in-order webhook delivery is not a thing any HTTP-based producer can truthfully promise; the dedupe and ordering guidance above is the real contract.
- Paid plans only, with per-plan endpoint caps (3/10/25). Free-tier suites lose nothing: long-polling — the actual test primitive — is on every plan.
- MailFixture is receive-only. These are notifications about received email and SMS; nothing here sends mail, and the
message.receivedevent fires for mail arriving at your test inboxes.
Where to go next
- The reference for everything this guide used — events, envelope, signatures, the retry ladder: webhooks concepts.
- The opposite direction — capturing and asserting the webhooks your own app sends: the webhook testing guide.
- If you came here to make a test react to an email, the long-polling patterns are the better tool: the OTP guide.
- Building the pipeline that email feeds into? The extraction results that ride in every
message.receivedpayload: extraction concepts.
If your app sends email and your infrastructure needs to react when it lands — create an endpoint, fire the test event, and watch it turn green in the delivery history before anything real depends on it.