MXcatch

Webhook reference

When mail arrives for an alias with a webhook action, MXcatch POSTs the parsed message to your endpoint as signed JSON.

The request

A single POST with a JSON body and three headers that matter:

HeaderValue
Content-Typeapplication/json
X-MXcatch-Eventemail.received
X-MXcatch-Signaturesha256=<hex digest>

The signature is an HMAC-SHA256 of the exact raw request body, keyed with the signing secret for that action. Verify it before you parse anything.

Payload

{
  "event": "email.received",
  "id": 48213,
  "message_id": "<CAF9x1c...@mail.gmail.com>",
  "received_at": "2026-08-29T09:14:02+00:00",
  "domain": "acme.com",
  "from": {
    "address": "notify@stripe.com",
    "name": "Stripe"
  },
  "to": "billing@acme.com",
  "subject": "Invoice paid",
  "text": "Your subscription to MXcatch Business has been renewed.",
  "html": "<div>Your subscription...</div>",
  "headers": {
    "Return-Path": "bounce@stripe.com",
    "Received-SPF": "pass"
  },
  "attachments": [
    {
      "name": "receipt.pdf",
      "mime": "application/pdf",
      "size": 49152,
      "sha256": "9f8e7d6c5b4a...",
      "url": "https://ams3.digitaloceanspaces.com/..."
    }
  ]
}
FieldTypeNotes
eventstringAlways email.received today.
idintegerMXcatch's own message ID. Stable across retries — use it to deduplicate.
message_idstringThe Message-ID header as written by the sender. Not guaranteed unique.
received_atstringISO 8601 with offset.
domainstringThe domain the mail arrived on.
fromobjectaddress and name; name may be null.
tostringThe address that matched the alias.
subjectstringDecoded. May be an empty string.
textstring|nullThe plain-text part, when the message has one.
htmlstring|nullThe HTML part, when the message has one. Treat as untrusted.
headersobjectThe parsed headers, useful for SPF results and custom X- headers.
attachmentsarrayEmpty when there are none. See below.

Attachments

Files are never inlined in the payload — a 40 MB PDF as base64 would make the request four times the size and time out half the handlers on the internet. Instead each attachment carries a pre-signed download URL, valid for 60 minutes, plus the metadata you need to check what you got:

  • name — the filename as declared by the sender. Sanitise it before writing to disk.
  • mime — the declared content type. Also sender-controlled, so verify rather than trust.
  • size — bytes.
  • sha256 — hex digest of the file. Compare it after downloading.
  • url — pre-signed, single-purpose, expiring. Do not store it; store the file.

Verifying the signature

Compute the HMAC over the raw body and compare it in constant time. Frameworks that hand you a parsed body have usually already re-encoded it — grab the raw bytes.

PHP

$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

if (! hash_equals($expected, $_SERVER['HTTP_X_MXCATCH_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

$payload = json_decode($raw, true);

In Laravel, reach for $request->getContent() rather than $request->all(), and exclude the route from CSRF verification.

Node

import crypto from 'node:crypto'

// express: app.post('/hook', express.raw({ type: 'application/json' }), handler)
function handler (req, res) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.MXCATCH_SECRET)
    .update(req.body)
    .digest('hex')

  const given = req.get('X-MXcatch-Signature') || ''

  const ok = expected.length === given.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))

  if (!ok) return res.sendStatus(401)

  const payload = JSON.parse(req.body)
  res.sendStatus(200)
}

Python

import hmac, hashlib, json

def handle(raw_body: bytes, signature: str, secret: str):
    expected = 'sha256=' + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise PermissionError('bad signature')

    return json.loads(raw_body)

Retries and idempotency

Return a 2xx and the delivery is done. Anything else — a 500, a connection reset, a timeout — schedules a retry. There are five attempts in total, spaced roughly 30 seconds, 2 minutes, 10 minutes and 30 minutes apart, so a handler that is down for half an hour still catches up on its own.

Because a response can be lost after your side has already committed, treat deliveries as at-least-once and key your processing on id.

Respond quickly. Acknowledge first and do the real work in a background job — a handler that spends 30 seconds calling an LLM before returning 200 will collect retries for messages it has already processed.

Hardening the endpoint

  • Verify the signature on every request, before parsing.
  • Use a distinct secret per webhook action so one leak does not compromise the rest.
  • Reject payloads whose received_at is far in the past to blunt replay attempts.
  • Treat html, subject, name and every attachment filename as attacker-controlled — anyone can email your domain.
  • Serve the endpoint over HTTPS. MXcatch does not follow redirects to plain HTTP.

Webhook questions

What HTTP status should my endpoint return?

+
Any 2xx acknowledges the delivery. Anything else — including a timeout — is treated as a failure and schedules a retry.

Can the same message be delivered twice?

+
Yes. If your endpoint processes a message but the response never reaches us, the delivery is retried. Deduplicate on the id field, which is stable across retries of the same message.

How long are attachment URLs valid?

+
Sixty minutes from the moment the payload is generated. Download within the request, or re-request the message from the inbox if a URL has expired.

Do you support Discord webhooks?

+
Yes. Discord and Slack incoming webhooks each have their own action type that formats the message for that platform — you do not need to write a handler for either.

Send your first signed payload.

Add a webhook action to any alias and email it — the free plan includes the inbox and the logs to debug with.

Create your free account