@flowmails/flowmails-sdk
The official JavaScript / TypeScript SDK for the Flowmails SDK backend (https://sdk.flowmails.net).
Status: public preview (v0.2.x) — published from the Flowmails monorepo. Wire format and surface can still change in v0.x; pin the minor version (
~0.2) in production.
Install
pnpm add @flowmails/flowmails-sdk
Quick start
import { Flowmails } from "@flowmails/flowmails-sdk";
const fm = new Flowmails({
apiKey: process.env.FLOWMAILS_API_KEY!,
// baseURL: "https://sdk.flowmails.net", // default
});
const result = await fm.send({
from: "[email protected]",
to: "[email protected]",
subject: "Order #1234 confirmed",
text: "Thanks for your order — we'll ship it tomorrow.",
// html: "<p>Thanks for your order…</p>",
});
console.log(result); // { id: "msg_8421", status: "queued" }
A send the relay refused
send() answers 201 for a message the relay refused — the row
exists, the mail does not. The result carries the reason, so a failure
is never silent:
const result = await fm.send({ … });
if (result.status === "failed") {
console.error(result.error); // "send_failed" — always present
console.error(result.sendFeedback); // the relay's own reason, when it gave one
}
result.error is never null on a failed send: "failed with no
reason" is not a state the wire can be in.
Is my domain able to send?
const me = await fm.me();
if (me.sending && !me.sending.canSend) {
console.warn(me.sending.state); // "not_onboarded" | "unconfigured" | "unknown" | …
}
me.sending is the platform's own verdict, read from the same document
the dashboard reads — null when it cannot be known (no bound domain,
or the platform could not be read). Read it together with state:
canSend: false with state: "unknown" means "we have not looked",
not "this domain cannot send".
About Flowmails
Flowmails is a Cloudflare-native email platform: custom-domain mail (inbox, outbox, and routing rules) that lives in your own D1, with a REST API and webhooks on top. The dashboard, docs, and blog live at https://flowmails.net — sign up there to mint an API key and bind a domain before using this SDK.
API keys
API keys are minted in the Flowmails dashboard at Settings → API Keys. Each key is pinned to one of your registered domains — the SDK backend rejects send(...) calls where the from address doesn't land on the key-bound domain.
Errors
The SDK throws typed errors:
| Class | HTTP status | Retryable |
|---|---|---|
AuthenticationError | 401 | no |
ValidationError | 400 / 404 / 409 / 422 | no |
RateLimitError | 429 | yes (client backs off) |
UpstreamError | 5xx / 408 | yes |
FlowmailsError | other | no |
Use instanceof to discriminate:
import { RateLimitError, FlowmailsError } from "@flowmails/flowmails-sdk";
try {
await fm.send({ ... });
} catch (err) {
if (err instanceof RateLimitError) {
// backoff and retry
} else if (err instanceof FlowmailsError) {
// surface `err.code` to your logs
} else {
throw err;
}
}
Idempotent sends
Give a send an idempotencyKey and a retry becomes free: the same key
under the same account and the same request returns the original
result and does not send a second message.
await fm.send({ from, to, subject, text, idempotencyKey: "order-42" });
The key is scoped to the account, not globally. Reusing one key for a
different payload is a client bug and is refused with
idempotency_key_conflict (a ValidationError) rather than answered
with the first result.
Send quota
Sends are capped per account and per domain, per hour and per day. A
refusal is a RateLimitError carrying the facts you need to back off:
catch (err) {
if (err instanceof RateLimitError && err.code === "rate_limit_exceeded") {
console.log(err.remaining, err.limit, err.resetAt); // ISO-8601
}
}
The same numbers travel in the X-Flowmails-RateLimit-Remaining and
X-Flowmails-RateLimit-Reset response headers. The SDK does not
retry a quota refusal for you — the window is an hour, so a retry
inside it cannot succeed.
The read half
Sending is one half of the SDK; the other is reading — and it
talks to a different origin (the platform worker, not the SDK
backend), so a self-hosted install sets platformURL too.
// Check the credential without sending anything.
const me = await fm.me(); // { userId, domain, scopes[], sending }
// Tail the inbound log with a cursor (an event id, never a timestamp).
const page = await fm.events.list({ after: lastEventId });
// Search the mailbox history — addressing, subject and a preview.
const hits = await fm.messages.search(domainId, { q: "invoice" });
// Read the conversation a message belongs to.
const thread = await fm.threads.get(threadId);
// Wait for a message without holding a request open.
const wait = await fm.waits.create({ address: "[email protected]" });
const status = await fm.waits.get(wait.waitId); // pending → resolved | expired
// Create a mailbox — one local part, idempotent.
await fm.addresses.create(domainId, "billing");
The body is fetched, never pushed: an event carries the routing
facts and the subject line, and fm.messages.get(messageId) is the
ask. The SDK never hands you a message you did not ask for.
Limitations (v0.2)
- Custom domain (HTTP/HTTPS) attachment hosting is not configurable by SDK callers; the SDK backend always sends via R2.
- The webhook delivery-status events (
message.delivered/message.bounced) do not exist yet — and neither does bounce detection. The receive worker has no DSN ingestion, so a send's status issent(the relay accepted it) orfailed(the relay rejected it) and nothing more. The vocabulary holdsdelivered/bounced/complained/delayed, but nothing writes them.
License
MIT © 2026 Flowmails. See LICENSE.