Docs / API

Available in v1.0

API endpoints

The outbound endpoints, the read / wait endpoints an agent polls, and a no-auth liveness probe. The contract below is the wire shape each worker enforces — the SDK re-shapes your typed payloads into this JSON before posting.

Endpoints

MethodPathAuthNotes
POST/api/v1/messagesBearer SDK keySend a message. Validates the payload, enforces from on the bound domain, forwards to the per-account receive-worker’s POST /api/email/send. Returns { id, status, messageId, sendFeedback, error, recipients } on 201 — a send the relay refused is still a 201, with status "failed", error "send_failed" and the relay’s own reason in sendFeedback. A domain with no outbound provider is refused before dispatch with a non-retryable 409 sending_not_configured — no quota slot is consumed and no failed row is written; sending is optional and can be enabled per domain in the dashboard. status is the verdict over every recipient — "sent", "partial" or "failed" — and recipients says what happened to each address.
GET/api/v1/messages/:idBearer SDK keyA sent message’s delivery state — status (sent or failed today), the SMTP messageId, the relay’s sendFeedback and the error slug. Accepts the row id or the Message-ID. SDK: fm.messages.status(id).
GET/api/v1/meBearer SDK keyThe connection check: who the key is — userId, the bound domain, the worker it sends through, its scopes, and sending — the outbound verdict (canSend / configured / state / detail / checkedAt), or null when it cannot be known. configured is the configuration fact: false means sending is optional and was never set up — a different answer from a configured-but-broken domain. Sends nothing. SDK: fm.me().
GET/api/v1/worker-versionBearer SDK keyThe receive worker this account runs, and the version the platform deployed for it — plus when that deploy happened and whether it failed. A worker below 1.6.0 does not report inbound mail, so an agent with an empty event log checks this first. SDK: fm.workerVersion().
GET/api/agent/eventsBearer SDK keyThe durable event log, read with a cursor (?after=), optionally filtered by recipient / sender / subject. Carries routing facts plus the subject — never a body. SDK: fm.events.list().
GET/api/agent/messages/:messageIdBearer SDK keyThe full content of one message, by SMTP Message-ID. The body is fetched, never pushed. SDK: fm.messages.get().
POST/api/agent/waitsBearer SDK keyOpen a durable wait for an address (optionally a sender and a subject substring). Returns { waitId, pollAfterMs } immediately — no suspended request. SDK: fm.waits.create().
GET/api/agent/waits/:waitIdBearer SDK keyA wait’s status: pending → resolved (with the Message-ID that satisfied it) or expired. Expiry is applied on read. SDK: fm.waits.get().
GET/api/agent/domainsBearer SDK keyThe domains (mailboxes) the key’s account owns, with each one’s routing mode and bound local parts. SDK: fm.addresses.listDomains().
GET / POST/api/agent/domains/:domainId/addressesBearer SDK keyList a domain’s addresses, or create one local part ({ localPart }). The only account-configuration write the agent surface has; idempotent, and it never changes the routing mode. SDK: fm.addresses.list() / fm.addresses.create().
GET/api/agent/threads/:threadIdBearer SDK keyThe conversation a message belongs to, oldest first. The id is the conversation root’s SMTP Message-ID — what email.threadId holds, never an intermediate parent’s. SDK: fm.threads.get().
GET/api/agent/emails/searchBearer SDK keySearch one domain’s mailbox (?q=, ?domainId=, ?limit=, ?field=, ?type=). A hit carries addressing, the subject and a short preview — never the body. SDK: fm.messages.search().
GET/healthnoneLiveness probe. Returns { ok: true }. Useful for uptime monitors. It does not verify a key.

The /api/v1/* and /health endpoints are served by the SDK backend at sdk.flowmails.net; anything outside them returns plain-text Not Found (404). The /api/agent/* endpoints are served by the same host as /api/v1/* — the agent platform moved to the SDK backend — and both accept the same fm_ key.

Scoped credentials

A credential comes in two shapes. The fm_ key is the one this documentation has always described: tenant-wide for reads and bound to one domain for sends, revocable in one click. The agc_ credential is the narrowed one — you issue it in Settings → Agent Credentials, tick only the capabilities the agent needs, and the platform refuses anything else with a typed { error: "scope_denied", required } (403). The raw key is shown once, at issuance; only its SHA-256 hash is stored, and revoking it takes effect immediately on every agent route.

The scope matrix is nine names: events:read, waits:read, waits:write, messages:read, messages:search, messages:send, send_status:read, addresses:read and addresses:write. A scope says which tools an agent may call — it is never an approval gate. Flowmails does not ask a human before the agent sends.

The MCP server

A hosted Model Context Protocol server answers POST /mcp on sdk.flowmails.net over streamable HTTP. It accepts the same two credential types, and every tool checks the credential’s scope before it runs.

ToolScopeBacking route
list_domainsaddresses:readGET /api/agent/domains
list_addressesaddresses:readGET /api/agent/domains/:domainId/addresses
create_addressaddresses:writePOST /api/agent/domains/:domainId/addresses
search_emailsmessages:searchGET /api/agent/emails/search
get_emailmessages:readGET /api/agent/messages/:messageId
send_emailmessages:sendPOST /api/v1/messages
get_send_statussend_status:readGET /api/v1/messages/:id
wait_for_emailwaits:writePOST /api/agent/waits
get_wait_statuswaits:readGET /api/agent/waits/:waitId
list_eventsevents:readGET /api/agent/events
claim_messagemessages:claimPOST /api/agent/messages/:messageId/claim
complete_messagemessages:claimPOST /api/agent/messages/:messageId/complete

Ten tools, deliberately: every extra tool is a way for an untested agent to change account configuration by accident. The same capabilities are also reachable through @flowmails/flowmails-sdk and the flowmails CLI.

Request body

The JSON below is what the SDK backend receives. The SDK re-shapes your typed SendOptions into this exact shape, so you shouldn’t need to read or build this JSON directly.

POST /api/v1/messagesjson
{
  "from": "support@yourdomain.com",
  "fromName": "Support",                       // optional — display name
  "to": ["customer@example.com"],               // string or string[]
  "perRecipient": true,                         // optional — one message per recipient (default)
  "replyTo": "noreply@yourdomain.com",          // optional — Reply-To header
  "replyToMessageId": "<abc@example.com>",      // optional — reply in thread
  "idempotencyKey": "order-1234-attempt-1",     // optional — dedupe a retry
  "subject": "Order #1234 confirmed",
  "text": "Plain text body",                    // optional but at least one
  "html": "<p>HTML body</p>",                   // optional but at least one
  "attachments": [                              // optional
    {
      "filename": "invoice.pdf",
      "content": "JVBERi0xLjQKJ...",            // base64 OR raw string
      "mimeType": "application/pdf"
    }
  ]
}

Constraints enforced by the handler before the request ever leaves the SDK backend:

  • from must be a valid RFC 5322 address and must land on the domain bound to the API key. A mismatch returns from_domain_mismatch (422).
  • idempotencyKey is optional and scoped to your account. With it, a retry replays the first answer instead of sending twice; the same key with adifferent payload answers idempotency_key_conflict (409). Without it, every call sends.
  • The send is counted against a quota — per account and per domain, per hour and per day. Exceeding it answers rate_limit_exceeded (429) with limit, remaining and resetAt, plus the X-Flowmails-RateLimit-Remaining/ X-Flowmails-RateLimit-Reset headers and Retry-After. The send is refused before it is dispatched.
  • Every to recipient must be a valid email address.
  • subject must be 1..998 characters.
  • At least one of text or html must be a non-empty string. Each body is capped at 1,000,000 characters.
  • The local-part of from must be 1..64 characters. The receive-worker does the route-table validation downstream.
  • replyToMessageId is the parent’s SMTP Message-ID (or its row id). The backend reads that row and derives In-Reply-To, References and the thread anchor — you never build those headers. A parent outside the key’s bound domain is reply_parent_not_found (404); a parent whose sender stripped the Message-ID is reply_parent_unthreadable (422).

Response

On success the SDK backend returns the message id with HTTP 201. The id is the numeric email.id row id from the receive-worker, stringified for JSON safety; messageId is the SMTP Message-ID the relay assigned — the key every read path takes.

201 Createdhttp
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "8421",
  "status": "sent",
  "messageId": "<abc123@yourdomain.com>",
  "sendFeedback": null,
  "error": null,
  "recipients": [
    {
      "to": "customer@example.com",
      "id": "8421",
      "status": "sent",
      "messageId": "<abc123@yourdomain.com>"
    }
  ]
}

Several recipients? Each one gets its own message by default — they cannot see each other, and one refused address does not hold up the others. status is then the verdict over all of them: "sent", "partial" (some were refused) or "failed", and recipients says what happened to each address — its row id, its own verdict and the relay’s reason when it was refused. error is "send_failed" for a partial send too: check it before treating a 201 as “sent”. Send one shared envelope to everybody instead with perRecipient: false — one message, one bill from a provider that charges per message, and then the send is all-or-nothing by construction.

Follow the send with GET /api/v1/messages/:id (fm.messages.status()): it returns sent (the relay accepted it) or failed (the relay rejected it), the relay’s error string in sendFeedback and the error slug beside it. A bounce is not observable — the status vocabulary holds delivered / bounced, but the receive worker has no bounce ingestion and never writes them. Push webhooks are shipped, for a message arriving and for a message being sent; the delivery-status half is not. The complete error envelope is documented on the Errors page.

Field renames between the SDK and the wire

The SDK keeps html as the body field name (it’s what email-library users expect), and the SDK backend re-shapes it to content when it forwards to the receive-worker’s /api/email/send. The attachments binary encoding and the single-recipient wrap happen entirely inside the SDK before the request leaves.

HopFieldTransform
SDK input → SDK → SDK backendhtmlPass-through. The SDK keeps html as the body field name; the SDK backend receives it as-is on the inbound payload.
SDK backend → receive-workercontentRenamed server-side in apps/sdk-backend/src/api/messages.ts: content: body.html. The receive-worker’s wire DTO uses content exclusively.
SDK input attachment.content (Uint8Array)attachment.content (string)Base64-encoded in the SDK via bytesToBase64 (uses btoa, no Buffer polyfill). Pass-through on every subsequent hop.
SDK input options.to (string)to (string[])Single recipient wrapped to [to]. Arrays pass through.

What the SDK does for you

  • JSON encoding. The SDK JSON.stringifys the body and sets Content-Type for you.
  • 15-second timeout. AbortSignal.timeout(15_000) matches the SDK backend’s upstream timeout, so you get UpstreamError rather than a generic 502 if the request stalls.
  • Auto-retries. The SDK retries 5xx, 408 and network failures with exponential backoff (capped at 4 s) up to maxRetries (default 2). The one 429 it retries is idempotency_key_in_progress — a retry there replays the first answer rather than sending twice. The send quota’s 429 is not retried: the window is an hour, and a retry would burn another slot. Other 4xx is never retried — see Errors.
  • Request id. If you set requestId on the constructor, every outbound request carries X-Flowmails-Request-Id for cross-stack tracing.

Reading and waiting

Inbound mail is durable platform state, not a long-poll. Every accepted message lands in an event log you read with a cursor, and a wait you open (and which answers immediately) is resolved for you when a matching message arrives. Neither suspends a request: an edge request dies long before a verification mail lands, so the state lives on the platform and you poll it.

The event is routing facts plus the subject — { messageId, event, recipient, sender, subject, createdAt } — and never a body. The content is an explicit ask (fm.messages.get(messageId)), which is why an agent is never handed a message it did not want.

wait-for-email.tsts
import { Flowmails } from "@flowmails/flowmails-sdk";

const fm = new Flowmails({ apiKey: process.env.FLOWMAILS_API_KEY! });

// 1. Open a durable wait. Returns immediately.
const wait = await fm.waits.create({
  address: "billing@yourdomain.com",
  subject: "invoice",        // optional substring constraint
});

// 2. Poll the platform — you can stop and resume; it is durable state.
let status = wait;
while (status.status === "pending") {
  await new Promise((r) => setTimeout(r, wait.pollAfterMs));
  status = await fm.waits.get(wait.waitId);
}

// 3. Fetch the body explicitly.
if (status.status === "resolved" && status.resolvedMessageId) {
  const mail = await fm.messages.get(status.resolvedMessageId);
  console.log(mail.text);
}

A wait is a shortcut, not the record: a message that lands one second after the deadline does not resolve the wait, but it is in the event log. An agent that only waits will eventually miss one; an agent that reads fm.events.list({ after }) cannot. Use both when it matters.

Creating the mailbox in the first place is fm.addresses.create(domainId, "billing") — idempotent, and the only account-configuration write the agent surface has. Mode changes, bulk edits, route templating and unbinding stay dashboard-only.

Next up