Webhooks
Get a signed POST the instant mail lands, with no more polling. Verify it with X-MailFlat-Signature.
Set one up
- Register a URLPOST /api/webhooks with the endpoint you control. Up to 10 per account.
- Store the secretThe whsec_… value comes back once, on creation. There is no way to read it again. Losing it means deleting the webhook and making a new one.
- Verify every deliveryCompare the HMAC of the raw body against the signature header, and reject on mismatch.
- Answer 2xx quicklyAny non-2xx (or a timeout) counts as failed and is retried.
# register (session token)
curl -X POST https://mailflat.net/api/webhooks \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"url":"https://your-app.com/hooks/mailflat","event":"message.received"}'
# → { "webhook": { id, url, event }, "secret": "whsec_…" }
# list · delete · fire a sample · inspect deliveries
curl https://mailflat.net/api/webhooks
curl -X DELETE https://mailflat.net/api/webhooks/{id}
curl -X POST https://mailflat.net/api/webhooks/{id}/test
curl https://mailflat.net/api/webhooks/deliveries
Line by linewhat each step of this curl example does
- event
- "message.received", a calendar event ("calendar.event.created", ".updated", ".cancelled", "calendar.attendee.responded", or "calendar.*" for all of them), or "*" for everything we may add later.
- secret: whsec_…
- Returned once. Store it in your secret manager immediately.
- /test
- Fires a sample payload synchronously and returns the delivery result, the fastest way to check your handler.
- /deliveries
- Recent attempts with status code and duration, for when a hook goes quiet.
No public URL yet?
Point the webhook at a catcher like webhook.site, then call POST /api/webhooks/{id}/test, so you can inspect the exact body and headers before writing a single line of handler code.
What we send
| Field | Always? | Meaning |
|---|---|---|
| event | yes | message.received |
| inbox | yes | The address that received the mail |
| from | yes | Sender address |
| received_at | yes | ISO 8601 in UTC, ends with Z |
| subject | plain-text inboxes only | Omitted when the inbox is encrypted |
| otp | plain-text inboxes only | Extracted code, or null if the mail has none |
| encrypted | encrypted inboxes only | true, a signal that subject and otp were deliberately withheld |
Calendar events have their own shape
calendar.event.created, .updated and .cancelled fire when an invitation changes the inbox calendar. They carry event, inbox, message_id (the mail that carried the invitation), received_at and calendar_event: the meeting itself, the same object GET …/calendar/events/{id} returns. A repeat of an invitation that changes nothing sends no event. Encrypted inboxes never fire them. calendar.attendee.responded fires when someone answers a meeting this inbox organized, and adds attendee ({email, name, status}). Only answers signed by the attendee’s own mail provider (DKIM) count, so nobody can answer on someone else’s behalf.
{
"event": "calendar.attendee.responded",
"inbox": "scheduler@a7f2c.mailflat.net",
"message_id": 48213,
"received_at": "2026-10-01T14:05:12Z",
"attendee": { "email": "jane@acme.com", "name": "Jane Doe", "status": "accepted" },
"calendar_event": {
"id": 311,
"uid": "mf-5b1e9c2a@mailflat.net",
"title": "Onboarding call",
"start": "2026-10-06T18:00:00Z",
"end": "2026-10-06T18:30:00Z",
"all_day": false,
"timezone": "America/New_York",
"location": "https://meet.google.com/abc-defg-hij",
"organizer": { "email": "scheduler@a7f2c.mailflat.net", "name": null },
"attendees": [
{ "email": "jane@acme.com", "name": "Jane Doe", "role": "req-participant", "status": "accepted" },
{ "email": "sam@acme.com", "name": null, "role": "opt-participant", "status": "needs-action" }
],
"status": "confirmed",
"source": "outbound",
"sequence": 0,
"recurring": false
}
}
A guest said yes to a meeting this inbox organized. attendee is who answered; calendar_event.attendees already holds everyone’s latest status. The calendar.event.* events look the same without attendee. A few calendar_event fields (description, timestamps) are left out here to keep it short. More in Agent calendar.
Encrypted inboxes send less, on purpose
We never put the subject or the code of an end-to-end encrypted message into a webhook, because we cannot read them, and shipping them would defeat the encryption. Your handler gets encrypted: true and should fall back to the UI.
| Header | Value |
|---|---|
| webhook-id | Event id. Stays the same across retries, so use it as your idempotency key |
| webhook-timestamp | Unix seconds. Reject anything older than your tolerance, 5 minutes is typical |
| webhook-signature | v1,<base64 HMAC-SHA256("id.timestamp.raw_body")> |
| X-MailFlat-Event | message.received |
| X-MailFlat-Signature | sha256=<HMAC-SHA256(secret, raw_body)> |
| User-Agent | MailFlat-Webhook/ |
The first three headers are Standard Webhooks
They follow the standard-webhooks spec, the same one Svix and several large providers use, so an off-the-shelf verification library works without MailFlat-specific code. Your signing secret is the part after whsec_, base64 decoded. Signing the id and the timestamp along with the body is what makes a captured request useless later: the signature still matches, but the timestamp is old and your tolerance check rejects it. X-MailFlat-Signature covers the body only and stays for existing integrations.
Verify the signature
import crypto from "crypto";
// Keep the RAW body, verifying a re-serialized object never matches.
app.post("/hooks/mailflat", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.MAILFLAT_WEBHOOK_SECRET)
.update(req.body).digest("hex");
const got = req.headers["x-mailflat-signature"] ?? "";
const ok = got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!ok) return res.status(401).end();
const payload = JSON.parse(req.body.toString());
res.status(200).end(); // 2xx = delivered
});
Line by linewhat each step of this Node (Express) example does
- express.raw(…)
- The single most common mistake: JSON middleware re-serializes the body and the HMAC never matches. Hash the bytes we sent.
- timingSafeEqual
- Constant-time compare. A plain === leaks timing information about the secret.
- res.status(200)
- 2xx marks the delivery successful. Anything else triggers the retry schedule.
Retries: what happens when your endpoint is down
Each event is attempted up to three times: immediately, then after 30 seconds, then after 5 minutes. The first 2xx stops the schedule. Delivery runs in the background, so a failing webhook never delays or loses the mail itself.
Because a retry can arrive after you already processed the event, make your handler idempotent, so key off inbox + received_at.