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:
| Header | Value |
|---|---|
Content-Type | application/json |
X-MXcatch-Event | email.received |
X-MXcatch-Signature | sha256=<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/..."
}
]
}
| Field | Type | Notes |
|---|---|---|
event | string | Always email.received today. |
id | integer | MXcatch's own message ID. Stable across retries — use it to deduplicate. |
message_id | string | The Message-ID header as written by the sender. Not guaranteed unique. |
received_at | string | ISO 8601 with offset. |
domain | string | The domain the mail arrived on. |
from | object | address and name; name may be null. |
to | string | The address that matched the alias. |
subject | string | Decoded. May be an empty string. |
text | string|null | The plain-text part, when the message has one. |
html | string|null | The HTML part, when the message has one. Treat as untrusted. |
headers | object | The parsed headers, useful for SPF results and custom X- headers. |
attachments | array | Empty 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_atis far in the past to blunt replay attempts. - Treat
html,subject,nameand every attachment filename as attacker-controlled — anyone can email your domain. - Serve the endpoint over HTTPS. MXcatch does not follow redirects to plain HTTP.