GUIDES / EMAIL WEBHOOKS

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.

NAMING This guide is about consuming the signed email-event webhooks MailFixture sends to you (an email landed, notify my infrastructure). Testing the webhooks your app sends is the opposite direction — capture hooks: the webhook testing guide.

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:

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:

the test primitive — no webhook involved
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:

message.received
{
  "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 —

the second half of notify-then-fetch
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:

the signature header
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:

Both SDKs ship a verification helper that does the parse, the timestamp check, and a constant-time comparison:

one call in either SDK
// 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:

receiver.ts — Express
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):

receiver.py — 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:

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:

fire a signed test event, read the result
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

SYMPTOM & CAUSEEVIDENCEFIX
Every delivery fails signature verification — verifying a re-serialized body instead of the raw bytesYour own 400s; delivery history shows last_status_code: 400Feed the framework's raw body (express.raw, request.get_data()) into the verify helper
Verification fails for one endpoint only — wrong secret (recreated endpoint, or a copy-paste of the masked secret_hint)Fails for one endpoint, works for anotherThe full secret exists only in the creation response — recreate the endpoint and store whsec_… properly
Downstream job runs twice — retry of a slow delivery; queue lacks a unique event keyTwo requests carry the same event.idMake event.id unique in the durable queue; ack after enqueue, work asynchronously
Events stop arriving entirely — endpoint auto-disabled after 5 consecutive exhausted deliveriesdisabled_at/disabled_reason set on GET /v1/webhooksFix the receiver, POST /v1/webhooks/{id}/enable, monitor consecutive_failures
Endpoint registration rejected — HTTP URL, or a private/internal address400 at POST /v1/webhooksWebhooks require HTTPS on a publicly routable host — use a public receiver, not an internal service
Consumer shows stale state as current — assumed arrival order = event orderAn old event's retry arrived after a newer eventOrder by payload timestamps; make handlers last-write-wins on received_at, not arrival
Payload has no email body — working as designedEvery payload, alwaysFetch GET /v1/messages/{id} with your API key when content is needed

What this doesn't do

Where to go next

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.

long-polling on every plan · webhook endpoints from Solo
Start free Read the webhooks docs