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" 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 timestamp |
| 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 |
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 |
|---|---|
| X-MailFlat-Event | message.received |
| X-MailFlat-Signature | sha256=<HMAC-SHA256(secret, raw_body)> |
| User-Agent | MailFlat-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.