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.

EventWhat it means
message.receivedAn inbound message was accepted for one of your mailboxes. The payload names the recipient, the sender and the subject — never the body.
message.sentA 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

the deliveryhttp
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:

the algorithmtext
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-Timestamp is more than five minutes from your clock.
verifying ittypescript
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-Id to 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, file and gopher are 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.