Docs / Webhooks
Webhooks
Point Flowmails at a URL you control and it will POST to it when something happens — a message arrives for one of your mailboxes, or one of your messages is sent. The request is signed, the delivery is retried, and every attempt is in a log you can read.
Configure an endpoint in Settings → Webhooks. The signing secret is shown once, at creation — copy it then, because it is never shown again.
The events
There are two, and the list is closed. Each endpoint picks the ones it wants.
| Event | What it means |
|---|---|
| message.received | An inbound message was accepted for one of your mailboxes. The payload names the recipient, the sender and the subject — never the body. |
| message.sent | A message was handed to the relay for sending. The payload names the message id, the from and the to — never the body. |
There is no bounce event. message.delivered and message.bounced do not exist yet: the receive worker owns the status transitions and the platform never sees them. Until that changes, poll fm.messages.status(id) — today it answers sent (the relay accepted it) or failed (the relay rejected it). The status vocabulary also holds delivered / bounced, but the receive worker has no bounce or DSN ingestion, so it never writes them: a bounce is not observable today. The roadmap says so too.
The request
POST <your-webhook-url>
Content-Type: application/json
X-Flowmails-Signature: sha256=<hex>
X-Flowmails-Timestamp: <unix seconds>
X-Flowmails-Event: message.sent
X-Flowmails-Delivery-Id: <stable across retries>
{
"id": "whd_8421",
"event": "message.sent",
"createdAt": "2026-07-06T12:34:56.000Z",
"data": {
"messageId": "<msg@example.com>",
"from": "billing@example.com",
"to": "ops@example.net",
"subject": "Your invoice is ready"
}
}The payload carries the routing facts and the subject line. It never carries a message body: the body is fetched, never pushed, so a webhook receiver never holds mail it did not ask for.
Verifying the signature
One HMAC, over the timestamp and the body together:
signed_payload = "<X-Flowmails-Timestamp>." + raw_request_body
signature = "sha256=" + hex(HMAC_SHA256(key = secret, message = signed_payload))- The key is the secret string’s UTF-8 bytes, verbatim — not hex-decoded, not base64-decoded.
- The timestamp is inside the signed material. A body-only signature would still verify tomorrow; this one does not.
- The body must be verified raw. Re-serializing parsed JSON changes the bytes, and the signature will not match.
- Reject anything whose
X-Flowmails-Timestampis more than five minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
const timestamp = req.headers["x-flowmails-timestamp"];
const signature = req.headers["x-flowmails-signature"];
const raw = await readRawBody(req); // the bytes, not a re-serialization
const expected =
"sha256=" +
createHmac("sha256", process.env.FLOWMAILS_WEBHOOK_SECRET!)
.update(`${timestamp}.${raw}`)
.digest("hex");
const ok =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));Delivery, retries and the log
- At least once. The delivery is recorded before it is attempted, so a crash cannot lose it. A receiver must be idempotent — use
X-Flowmails-Delivery-Idto dedupe. It is stable across the retries of one delivery. - Retried. A failed attempt is retried on a bounded backoff — five minutes, then thirty, then two hours, six, twenty-four. Five attempts in all.
- Logged. Every attempt is a row: the status, the HTTP status, the reason. The log is kept for 30 days, and it is the answer to “did my endpoint get it?”
- Never fatal. A webhook that cannot be delivered never fails the ingest or the send. The delivery is a fact about a side channel, not a condition of the mail.
The URL guard
A webhook URL is a URL you type, and the platform is the one making the request. That combination is a server-side request forgery unless the guard is real, so it is:
- https only. One scheme is allowed.
http,fileandgopherare not on a deny-list — they are simply not the one allowed scheme. - The host is resolved and every answer is checked.Loopback, private, link-local (including the cloud metadata address), CGNAT, multicast, reserved and documentation ranges are refused, in IPv4 and IPv6.
- Re-checked on every attempt, not once at creation — a DNS answer can change between attempts.
- Redirects are not followed. A 3xx is a failed delivery, not a reason to try the address it points at.
The one residual risk, stated plainly: the guard resolves the name, checks the answer, and then the request goes to the name — a platform cannot pin a connection to a checked address without breaking TLS. The window is small and it is re-checked on every attempt.
Where the webhook ends
- It is not an approval gate. A scope decides which tools an agent may call; nothing in the webhook path asks a human to approve a send.
- It does not carry mail. The event says a message exists and where to fetch it. The body stays in your own mailbox.
- It cannot be pointed at your own network. The guard refuses private addresses, every attempt.