MailFlatDocs
Documentation/Guides/Webhooks

Webhooks

Get a signed POST the instant mail lands, with no more polling. Verify it with X-MailFlat-Signature.

Set one up

  1. Register a URL
    POST /api/webhooks with the endpoint you control. Up to 10 per account.
  2. Store the secret
    The 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.
  3. Verify every delivery
    Compare the HMAC of the raw body against the signature header, and reject on mismatch.
  4. Answer 2xx quickly
    Any non-2xx (or a timeout) counts as failed and is retried.
curl
# 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" 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

FieldAlways?Meaning
eventyesmessage.received
inboxyesThe address that received the mail
fromyesSender address
received_atyesISO-8601 timestamp
subjectplain-text inboxes onlyOmitted when the inbox is encrypted
otpplain-text inboxes onlyExtracted code, or null if the mail has none
encryptedencrypted inboxes onlytrue, a signal that subject and otp were deliberately withheld
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.
HeaderValue
X-MailFlat-Eventmessage.received
X-MailFlat-Signaturesha256=<HMAC-SHA256(secret, raw_body)>
User-AgentMailFlat-Webhook/1.0

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.