Skip to main content
Getting started File 003

The Postboi provider

Zero-config sending. One command, one token, no provider account, no DNS.

This is Postboi’s own email service, and the easiest way to send. We run the sending infrastructure, so you don’t need an account with any other provider, DNS records or a card. Run one command, authorise in the browser, and mail() works. The mail itself is delivered by Amazon SES in the EU, each team as its own SES tenant; how that’s kept healthy is on Deliverability, and how fast it arrives is measured on the delivery record.

bunx postboi init
npx postboi init
pnpm dlx postboi init
yarn dlx postboi init

Pick Postboi when prompted. The CLI opens your browser to authorise the device, then writes a single env var: POSTBOI_TOKEN, your API key (keep it secret). Everything else is config, not environment: it offers to set defaults (to, reply_to, cc, bcc, and from, once you have custom domains to choose between) and writes them to a committed postboi.config.ts, the same file where you can add hooks later. The CLI knows your domains and their verification status, so it won’t accept a default from at a domain that isn’t on your account (listing the ones that are), and warns when the domain is still pending verification.

Re-running init is safe: when a working POSTBOI_TOKEN is already in your environment it’s reused rather than replaced, so you can walk through the CLI again any time to revisit your defaults.

That’s the whole setup:

import { mail } from "postboi"

await mail({
	to: "someone@example.com",
	subject: "Hello",
	body: "<p>Sent through the Postboi provider</p>",
})

from is optional: when omitted entirely, the API uses your account’s sending address, which the token identifies. Env vars still override config for per-environment tweaks (POSTBOI_FROM beats default.from), but nothing needs to live in the environment except the token.

The Postboi provider also includes managed invisible captcha for your forms: one script tag from the dashboard, no Cloudflare account, no keys. See Spam protection.

Zero setup for agents & CI

postboi init --agent removes the last human step. No browser, no sign-in, no prompts: one API round trip mints a claimable project and the CLI wires everything exactly as the interactive flow would:

bunx postboi init --agent
  • The token lands in .env (gitignored automatically), the config is written, the package installed, the agent skill linked.
  • Your sending address is your project’s name. The package.json name seeds the slug: @acme/mail-site sends from mail-site@send.postboi.email (a suffix is added if it’s taken, and a friendly random slug stands in when the name can’t be an address).
  • Sends work immediately, sandboxed. Every send runs the full pipeline (message log, webhooks, status), but nothing is delivered and nothing can reach a real inbox. The response says so: { id, sandbox: true, claim_url }.
  • One sign-in makes it real. The CLI prints a claim URL; opening it and signing in (a magic link, which is the account creation too) transfers the project to you and lifts the sandbox. Everything the agent built keeps working unchanged, and the sandboxed sends are waiting in your message log.
  • Unclaimed projects expire. A project nobody claims and nobody uses for 14 days is deleted. Activity counts: while your token keeps sending sandboxed mail, the project stays alive for you to claim. But 60 days unclaimed is the ceiling, active or not.

In development none of this even matters yet. With the dev inbox running, mail is captured locally before the provider is consulted, so the sandbox only shows up in deployed or production-mode sends.

If you’re an AI coding agent: run init --agent when no POSTBOI_TOKEN exists and no human is available to sign in, then surface the claim URL prominently in your summary. It’s the one thing only your human can do. postboi whoami re-prints it any time. The other channels accept the flag too (init --push --agent mints VAPID keys and wires the service worker with no prompts); channels that need paid credentials (SMS, WhatsApp) will tell you exactly which env var they’re missing instead of prompting.

--agent also detects the project’s domain (astro site, package.json homepage, a CNAME file, wrangler routes, SITE_URL-style env vars) and prints it as a suggestion rather than registering it. Ownership is the human’s to assert, and the API refuses domains on unclaimed projects outright, so an anonymous token can never squat one. After the claim, bunx postboi domains add <domain> prints the records and the one-click registrar link.

Your sending address

Free-tier mail goes out from you@send.postboi.email, derived from your signup email. It’s a real, deliverable address on our reputation-managed sending domain. Set reply_to when you want responses to go straight to a particular inbox:

await mail({
	to: "someone@example.com",
	reply_to: "you@yourdomain.com",
	subject: "Hello",
	body: "<p>Replies come to you</p>",
})

You can rename the address (once a day) from the dashboard.

Replies

Your sending address is also a mailbox. Anything sent to it, including a reply to a message that never set reply_to, lands in Messages → Received in the dashboard, with the body, the sender, and a link back to the send it answers.

That happens whether or not you configure anything. Two things you can add on top:

  • Forward to your own inbox. On the dashboard overview, under Replies, give an address and click the verification link Cloudflare emails it. From then on replies arrive in your normal inbox as well as the dashboard. (Replying from there goes out from your own address, not your Postboi one.)
  • Handle them in code. Subscribe a webhook to email.received and each reply arrives as a normalized received event: the basis for a support inbox, a reply-to-confirm flow, or ticket creation.

Plus-tags route to the same mailbox, so you+order-1234@send.postboi.email is a per-thread address you can hand out and match on the way back:

await mail({
	to: "someone@example.com",
	reply_to: "you+order-1234@send.postboi.email",
	subject: "Your order",
	body: "<p>Just reply to this email</p>",
})

Inbound is free on every plan. Mail to an address nobody owns is rejected rather than swallowed, and inbound HTML is sanitised before it’s stored.

Receiving on your own domain

Your domain receives as well as sends, on a dedicated subdomain, and for a new domain it’s part of standard setup: the two records it needs (a TXT and an MX, both on reply.yourdomain.com) are in the same list as the sending ones, covered by the same one-click registrar apply where yours supports it. Once they’re live, mail to any address on that subdomain (support@reply.yourdomain.com, ada+ticket-42@reply.yourdomain.com) lands in Received, fires email.received, and can be answered from the dashboard, where the reply goes out from the address the mail was written to. The switch on the domain turns receiving off (and back on for domains added before this was the default, or via POST /v1/domains/{id}/inbound).

The MX lives on the subdomain on purpose. Pointing your domain’s own MX at us would take over your real mailboxes, so that is never asked for. you@yourdomain.com keeps working exactly as before, wherever it’s hosted.

Sending from your own domain

init offers this at the end of setup. It detects your project’s domain (astro site, package.json homepage, a CNAME file, wrangler routes, SITE_URL-style env vars) and prefills the prompt, so accepting is one Enter. Or do it any time from the terminal (bunx postboi domains add yourdomain.com prints the records and a one-click registrar link) or the dashboard: add a domain, publish the three DKIM CNAME records it shows you, and hit Check. Every plan includes one custom domain, Free included, and paid plans are uncapped (domain_limit_exceeded past the cap). Once verified, any address at that domain is a valid from:

await mail({
	from: "hello@yourdomain.com",
	to: "someone@example.com",
	subject: "Hello",
	body: "<p>From your own domain</p>",
})

Letterhead & the shell

The body of a send from code is your document, so Postboi writes nothing into it unless you ask. Two things can be asked for, and a send can ask for both.

The letterhead is the header and footer your team writes once in the dashboard under Messages → Templates → Letterhead: the masthead, the footer with the unsubscribe line:

await mail({
	to: "someone@example.com",
	subject: "Your receipt",
	body: "<p>Thanks for your order.</p>",
	letterhead: true,
})

The two halves are styled and put around your HTML, inside <body> when the body is a whole document, so nothing ends up before the doctype. Nothing else about the message changes.

The shell is the 600px column the dashboard composer sends in: the sheet on its ground, the drawn edge with its offset shadow, the brand’s three faces, and the dark palette for a recipient whose device asks for one. Hand it plain semantic HTML and it comes back a designed email:

await mail({
	to: "someone@example.com",
	subject: "Your receipt",
	body: "<p>Thanks for your order.</p><p><a href='https://example.com/orders/1'>View it</a></p>",
	shell: true,
	letterhead: true,
})

Buttons and other blocks

Inside the shell the composer’s own blocks work from code too, because it is the same renderer. You write a plain div with a data-* attribute, and it comes back as the same table-based markup a dashboard send gets. Every client draws it the same way, and it picks up your team’s brand (accent colour, button shape, corner radius) and the dark palette.

await mail({
	to: "someone@example.com",
	subject: "Your magic link",
	shell: true,
	body: `
		<p>Click the button to sign in:</p>
		<div data-button="yellow"><a href="${url}">Sign in</a></div>
		<p>Or paste this link into your browser: <a href="${url}">${url}</a></p>
	`,
})
Block Markup Values
Button <div data-button="yellow"><a href="…">Label</a></div> yellow (your accent), ink, outline, c1–c6 (your brand palette), or #rrggbb
Band <div data-section="ink">…any blocks…</div> ink, yellow, tint, paper, c1–c6, or #rrggbb
Spacer <div data-spacer="32"></div> height in px, 4–200
Stamp <div data-stamp>Paid</div> the label
Colour <span data-colour="c2">…</span> c1–c6 or #rrggbb
Align data-align="center" on a button, p, heading or img left, center, right

A button’s div holds exactly one <a> and nothing else. The label runs to the first </a>. Anything the renderer doesn’t recognise loses its data-* attributes and stays a plain div. So a typo gives you an unstyled link, never a broken email.

The blocks only render inside the shell. Without shell: true your HTML is sent exactly as you wrote it, so the data-* attributes do nothing. Classes such as pb-button don’t do anything either way: the renderer adds pb-* classes to its own output for the dark palette, and never reads them from yours.

Long words and bare URLs wrap inside the column rather than widening it, so a fallback “paste this link” line is safe to write as it is.

The shell sets the body rather than wrapping it (that is how a bare <p> comes back typeset), but what you wrote still wins. Its typesetting is written behind your own declarations, so this stays red:

await mail({
	shell: true,
	letterhead: true,
	style: "styled",
	body: `<p style="color: red">Still red!</p>`,
})

One consequence worth knowing: a tag you colour keeps that colour on a device in dark mode, where a tag you left alone is re-inked along with the rest. That is the same rule the renderer already follows for its own fixed-colour bands: a colour somebody chose is not ours to overrule.

The one thing the shell refuses is a body that is already a whole HTML document. The shell is the page, doctype and all, so what it wraps has to be a fragment; send the contents rather than the page, and you get a 400 saying so if you don’t. If you render a complete email with Maizzle, React Email or MJML, the letterhead on its own is the option you want.

The cut

style is the cut anything rendered on the send is set in, "styled" by default:

await mail({ …, shell: true, style: "plain" })

"plain" is the same blocks in the client’s own face: flat keys, no ground, no drawn column, no fonts asked for. It says how, not what: on its own it renders nothing, and one cut covers the letterhead and the shell together so they can’t disagree.

There is no theme to pick. Light or dark is the recipient’s device’s to decide, so it is never a setting on a message; both palettes go out and the device chooses.

A {unsubscribe_url} in the footer (the usual reason to write one) fills from unsubscribe_url on the same send, the option that also sets the List-Unsubscribe headers:

await mail({
	to: "someone@example.com",
	subject: "This month",
	body: "<p>…</p>",
	letterhead: true,
	unsubscribe_url: "https://example.com/unsubscribe/abc",
})

Braces anywhere else are yours: the fill runs on the letterhead’s halves and never on the body you wrote.

A team with no letterhead saved, or a send carrying only text, is a no-op rather than an error. A key scoped to a client workspace gets that workspace’s letterhead, never the agency’s.

The preheader

preheader is the line an inbox shows after the subject and the message itself never does, like “Your order is on its way”, where the subject is the order number:

await mail({
	to: "someone@example.com",
	subject: "Order #1024",
	body: "<p>Thanks for your order.</p>",
	preheader: "Your order is on its way",
})

It is written into your HTML as a hidden div, first. It goes ahead of a letterhead’s masthead, because what an inbox previews is the first text it finds, and a masthead would otherwise be the preview. It needs no shell, and a long one is clipped rather than refused.

The footnote

footnote is one send’s line of small print under the message: the reassurance under a sign-in email, which says something different on every kind of mail the team sends:

await mail({
	to: "someone@example.com",
	subject: "Sign in to Acme",
	body: `<h1>Sign in to Acme</h1><div data-button="ink"><a href="${url}">Sign in</a></div>`,
	shell: true,
	letterhead: true,
	footnote: "Didn't request this? You can safely ignore this email.",
})

With the shell it sits on the ground under the frame, centred, in the muted ink. The letterhead is what stays the same: its header and footer can each be set outside the frame on the dashboard’s Letterhead page, so a logo can sit in the sheet while the address and the unsubscribe line sit under it. A footnote goes nearest the message: after a footer that is in the frame, before one that is outside it. Without the shell there is no frame to be under, and it is simply the last thing in the body.

It is plain text: escaped, with line breaks kept, and at most 500 characters.

All five options are the Postboi provider’s own (what they ask for lives on your account), so naming one under any other provider is a type error.

Saying it once

How mail looks is usually a project-level decision, not a per-call one. Three of the five can be defaulted, and a send that names one still wins:

// postboi.config.ts
export default config({
	default: { from: "hello@acme.com", letterhead: true, style: "plain" },
})

POSTBOI_LETTERHEAD, POSTBOI_SHELL and POSTBOI_STYLE say the same thing from the environment and win over the file. Reach for those in a runtime that bundles its own server code, such as a Convex deployment. There is no filesystem to read postboi.config.ts from and no bundler plugin to inline it, so the file is simply absent at runtime, and unlike a missing from an unread letterhead throws nothing. It just looks wrong a fortnight later. bunx postboi doctor says so when it spots that shape, and importing the config from the file that sends (import "../postboi.config") is the other way out, since importing it registers it.

preheader and footnote are deliberately not defaultable: each describes one message, and a line that is the same on every email belongs in the letterhead.

There is a third place to say it, and it reaches every runtime because none of this is involved: the Sends from your code switch on the dashboard’s Letterhead page. That is the team saying “our API mail wears the letterhead” once, server-side, for sends that don’t mention it either way. letterhead: false on a send still sends bare.

Team credentials

Your channel credentials (a Resend key, a Twilio SID, a Slack webhook) sync through your Postboi account, so a teammate (or your next machine) gets a working setup from one command:

bunx postboi sync   # pulls every synced credential your local env is missing

postboi init pushes credentials up as it collects them and pulls them back down as it asks: a prompt whose value the team already synced answers itself. So for most projects this is invisible: one person runs init --sms and types TWILIO_AUTH_TOKEN once, ever; every teammate’s init or sync (already in the prepare script) fills it in from the team. Zero ceremony.

The rules, because these are secrets:

  • Local values always win. sync only writes keys your environment is missing; a deliberate local override is never clobbered. postboi env pull --force is the explicit way to take the team’s values wholesale.
  • POSTBOI_TOKEN never syncs. It’s per developer, and it’s the credential that unlocks the rest, and the store must not contain its own key.
  • Encrypted at rest, and only ever decrypted for a bearer of your account token: the same trust that could already send with those credentials.
  • Used server-side for exactly three things. The library itself sends with whatever is in your process environment, servers included. The synced store never feeds your send path. On Postboi’s side, synced credentials are decrypted only to poll a provider that can’t push delivery webhooks (SMTP, Microsoft 365, Cloudflare; on by default once synced, pausable per provider on the credentials page); to send on your behalf when you’ve turned on send via there; and, for a provider whose delivery events you point at us, to verify those webhooks with its signing secret, plus to register the webhook with that provider when you press Register, which stores any key it hands back. Never otherwise.

See what’s synced, push a hand-set var, or remove one:

bunx postboi env                # list (values masked)
bunx postboi env push           # push every known credential from your local env
bunx postboi env remove OLD_KEY

Relay: send via your own provider

With credentials synced, the credentials page grows a Send via setting: pick a provider and the account’s sends go out through it. Postboi keeps the message log, the suppression list, webhooks and the dashboard timeline, while your Resend, Postmark, Cloudflare (or any other synced provider) does the delivering. Your code doesn’t change: you keep sending through the Postboi provider exactly as before.

// Or per send, without touching the account setting:
const mail = new Postboi({ send_via: "resend" })

What flows back depends on the target:

  • SMTP, Microsoft 365, Cloudflare, the providers without webhooks, get their delivery and bounce events polled (see webhooks) and correlated into the timeline, so a relayed send shows Delivered/Bounced like a native one.
  • Webhook-capable providers (Resend, Postmark, …) report events to whatever webhooks you configure with them; the Postboi timeline shows Sent only. Cloudflare’s send response reports per-recipient verdicts immediately, bounces included.

Two honest notes: set from to an address the relay provider can send as (the usual from rule is skipped for relayed sends; your provider enforces its own sender authentication, and anything it can’t authenticate fails with its error), and relayed sends still count toward your Postboi plan like any other send.

Type-safe from

postboi init (and bunx postboi sync) generate types from your account’s sending address and domains, narrowing from so TypeScript rejects addresses you can’t send from, before the API does it at runtime:

await mail({ from: "foo@unknown-domain.com", ... })
// ^ Type error: must be your send.postboi.email address or an address
//   at one of your domains. Run `bunx postboi sync` to regenerate.

Display-name form works too ("Joe Bloggs <hello@example.com>"), and pending domains are included deliberately: you can write the code while DNS propagates; deliverability is enforced at send time either way (from_not_allowed).

The generated types live inside the installed package (node_modules/postboi), so there’s no file in your project: nothing to commit, gitignore, or see in diffs. Three consequences of that:

  • A reinstall resets them. init adds a "prepare": "postboi sync" script that restores them after every install (chained onto your existing prepare script, if any).
  • They’re always optional. Without them (fresh clone, CI without a token, teammate who hasn’t run init), from falls back to plain string: builds and deploys never fail because the types are missing. sync itself is a quiet no-op without a POSTBOI_TOKEN and always exits 0, so it’s safe anywhere.
  • They’re a snapshot. Re-run bunx postboi sync after adding or removing a domain (your editor may want a TS-server restart to pick the change up).

This only applies to the Postboi provider (we can’t know another provider’s identities). If you mix Postboi with a bring-your-own provider in one project, remove postboi sync from your prepare script: the narrowing applies to from everywhere.

form is looser: sync reads your account’s forms so mail({ form: "Home Ownership Query" }) autocompletes, but any other name is accepted too, since a form is created on its first send. See Naming the form.

Limits

Plan Price Included Daily cap Overage Custom domains
Free £0 3,000/mo 100/day none (hard) 1
Starter £9/mo 40,000/mo none £0.40/1k unlimited
Pro £25/mo 100,000/mo none £0.35/1k unlimited
Scale £99/mo 500,000/mo none £0.30/1k unlimited

The free tier stops at its caps; paid tiers keep sending and meter the overage. Current prices are on postboi.app/pricing. Every plan has a burst rate limit. When a limit is hit, mail() throws a PostboiError with a machine-readable code:

Code Meaning
daily_limit_exceeded Free-tier daily cap: resets at midnight UTC
monthly_limit_exceeded Free-tier monthly wall: upgrade to keep sending
rate_limited Burst limit: back off and retry
from_not_allowed from isn’t your address or a verified domain
domain_limit_exceeded Free includes 1 custom domain: upgrade to add more
sending_paused Bounce/complaint rate tripped the safety threshold

Delivery status

Every send appears in the message log with its delivery status: bounces and complaints are tracked automatically. High bounce or complaint rates pause sending to protect deliverability for everyone; the dashboard shows when that happens.

You can also look a message up from code with the id mail() returned:

const message = await mail.messages.get(id)
// { id, status: 'sent', to, subject, opened_at, open_count, … }

And a scheduled message can be moved (until it sends). mail.messages.reschedule takes the same formats as scheduled_at:

await mail.messages.reschedule(id, { days: 2 }) // or a Date / ISO 8601 string

Batching & idempotency

Personalized batches go out as one request to the batch endpoint (up to 100 recipients per call) instead of one per recipient:

await mail.send({
	to: ["ada@example.com", "linus@example.com"],
	subject: "Hey {name}",
	body: "<p>Hi {name}</p>",
	data: {
		"ada@example.com": { name: "Ada" },
		"linus@example.com": { name: "Linus" },
	},
})

Sends accept an idempotency_key: retrying a send with the same key returns the original message id instead of delivering a duplicate. Pair it with retries for safe automatic retry.

A batch takes one key and gives each recipient its own, suffixed with that recipient’s position: order-42 becomes order-42:0, order-42:1 and so on. One key names one message, so a batch whose items shared a key would be a batch claiming to be a single message; the suffix is what keeps a retry replaying item by item, returning the ids the first attempt got and sending only what never went. The position is the one in your original to array, so a before.send hook skipping a different recipient the second time round doesn’t shift the keys onto other people’s messages.

Keys are capped at 256 characters including the suffix. A base key too long to carry one is refused before anything is sent, rather than silently truncated into a key that could collide with another.

Lists & broadcasts

The dashboard’s recipient lists are available from code, so a newsletter signup can go straight onto a list without leaving your app. One import, one call:

import { mail } from "postboi"

await mail.recipients.add("Newsletter", "Ada Lovelace <ada@example.com>")

mail.recipients.add upserts on both sides. The first argument is a list name or id (an unknown name creates the list), and re-adding an address updates its name and data instead of duplicating it. Recipients take the same shapes as to: a bare address, "Name <a@b.c>", { email, name?, data? }, or an array mixing all three. (List names are unique per account, so mail.lists.create rejects a taken name with code name_taken.)

Every list method accepts a name or an id. Only mail.recipients.add creates a missing list; everything else 404s on an unknown name. The response reports added (genuinely new addresses) and updated (existing ones refreshed), so calling it twice with the same address adds once:

await mail.recipients.add("Newsletter", [
	{ email: "ada@example.com", name: "Ada", data: { plan: "Pro" } },
	"Linus <linus@example.com>",
]) // → { added: 2, updated: 0, list: { id, name } }

await mail.lists.broadcast("Newsletter", {
	subject: "Hey {name}",
	body: "<p>News for our {plan} users…</p><p><a href='{unsubscribe_url}'>Unsubscribe</a></p>",
	scheduled_at: { hours: 1 }, // optional: omit to queue immediately
})

{key} placeholders are filled per recipient from their data (plus {name} and {email} from the recipient row), and every broadcast automatically carries the one-click unsubscribe headers Gmail and Yahoo require for bulk mail. For a visible opt-out link in the body, drop in {unsubscribe_url}, a reserved variable filled with that recipient’s signed one-click link (the same target as the header). The rest of the surface: mail.lists.all(), mail.lists.get(id) (with recipients), mail.lists.rename(id, name), mail.lists.delete(id), and mail.recipients.remove(list_id, email).

Contacts (the audience)

A contact is one address on your account. Its name and data live once and are shared across every list it’s on (not copied per list). Lists are how you segment that audience; a mail.recipients call upserts the contact and its membership together, so you rarely touch contacts directly. When you do, mail.contacts is the whole audience:

await mail.contacts.add("ada@example.com", { name: "Ada", data: { plan: "pro" } })
const ada = await mail.contacts.get("ada@example.com") // contact + its memberships
await mail.contacts.update("ada@example.com", { data: { plan: "team" } }) // global; last write wins
await mail.contacts.update("ada@example.com", { phone: "+447788223344" }) // the number SMS and WhatsApp reach
await mail.contacts.lists("ada@example.com") // which lists is Ada on?
await mail.contacts.all({ list: "Newsletter", status: "subscribed", search: "ada" }) // page (and follow) the audience
await mail.contacts.remove("ada@example.com") // drops the contact and all its memberships

A contact’s phone is its delivery profile beyond email: one mobile number in E.164 (+447788223344; a national number is rejected rather than guessed at), the one an sms() or whatsapp() to that person goes to. Email stays the handle; the number is a fact about the contact, searchable alongside the name.

Because data is the contact’s, setting it through mail.recipients.add(list, { email, data }) writes the contact’s global data, and the same values fill {key} in a broadcast from any list. Deleting a contact removes it from every list but does not suppress it; a hard bounce or complaint is suppressed separately (see Suppressions).

New in 0.19 (breaking). Recipients became contacts: name/data are now the contact’s, shared across its lists (last write wins) rather than stored per list; the per-list status enum is subscribed | pending | unsubscribed (bounced/complained are suppressions, not a status); and mail.contacts.* is a new namespace. mail.recipients.* keeps the same signatures. It’s contact-backed now.

Confirmation (double opt-in)

Lists can require confirmation: new recipients start pending and receive an email with a personal confirm link; they only receive broadcasts (and count as new subscribers for notifications) once they click it. Manage it from the list’s Confirmation tab, or from code:

await mail.lists.update("Newsletter", { confirmation: true }) // or on create:
await mail.lists.create("Digest", { confirmation: true })

await mail.recipients.add("Newsletter", "ada@example.com")
// → { added: 1, updated: 0, pending: 1, list: … } — Ada gets the confirmation email

A membership carries a status: subscribed, pending or unsubscribed. Only subscribed members receive broadcasts and digests; an unsubscribe keeps the membership (with history) but out of every send. Hard bounces and complaints aren’t a per-list status. They suppress the address account-wide, and the send path drops suppressed addresses on its own (see Suppressions). Set a membership’s status explicitly too:

await mail.recipients.add("Newsletter", "ada@example.com", { status: "pending" })
await mail.recipients.set_status("Newsletter", "ada@example.com", "unsubscribed")

Two knobs, patchable via an object: enabled (send confirmation emails) and default_status (what new recipients start as). confirmation: true is shorthand for strict double opt-in (email + "pending"); a courtesy email without gating is { enabled: true, default_status: "subscribed" }; off again is confirmation: false. The object also takes subject, body (HTML with {key} variables plus {list} and {confirm_url}, which goes in a link) and from. Settings come back on mail.lists.get(), and new members start "subscribed" or "pending" per the list’s default_status.

Notifications

Each list can carry notifications: digests of new subscribers emailed to whoever should know, on a schedule or the moment someone joins. The dashboard’s Notifications tab manages them visually; the same objects are available from code:

await mail.notifications.create("Newsletter", {
	recipients: "Darby <darby@uilo.co>",
	schedule: "subscribe", // fire when someone new joins
})

await mail.notifications.create("Newsletter", {
	recipients: ["darby@uilo.co", "team@uilo.co"],
	schedule: { frequency: "weekly", days: [1, 4], send_time: "09:00", timezone: "Europe/London" },
})

schedule takes a bare frequency ("daily", "weekly", "monthly", "subscribe") or an object with days (JS weekday numbers, weekly), month_day (monthly), send_time and an IANA timezone. Defaults are Mondays, 09:00, UTC. Subject and body default to a starter template; bodies are HTML with {key} variables plus {#if}/{#each} blocks over new_subscribers. The rest of the surface: mail.notifications.all(list), mail.notifications.update(list, id, changes) (partial: absent fields keep their values), and mail.notifications.delete(list, id).

Suppressions

Hard bounces, complaints and unsubscribes land on your account’s suppression list, and sends to those addresses are dropped automatically. Inspect and manage it from code:

const rows = await mail.suppressions.all() // [{ channel, email | phone, reason, detail?, created_at }]
await mail.suppressions.add("noisy@example.com") // add by hand
await mail.suppressions.remove("fixed@example.com") // allow sending again

The list is per channel. An email address is one entry; a phone number is suppressed for SMS and for WhatsApp separately, because “stop texting me” and “stop messaging me on WhatsApp” are two different things a person can say. A bare string is an email; a number goes in as { phone }, SMS unless told otherwise:

await mail.suppressions.add({ phone: "+447788223344" }) // SMS
await mail.suppressions.add({ phone: "+447788223344", channel: "whatsapp" })
await mail.suppressions.all({ channel: "sms" }) // just the numbers, just that channel

Each row says which it is. Narrow on channel before reading the address, because an email row carries email and a text row carries phone, never a number in a field called email:

for (const row of await mail.suppressions.all()) {
	if (row.channel === "email") console.log(row.email, row.reason)
	else console.log(row.channel, row.phone, row.reason)
}

A texted STOP reaches the list on its own when Twilio delivery receipts are polled for the account (see opt-outs on the SMS page), and the same { phone } shape adds one by hand from wherever else a reply arrives.

Notes

  • scheduled_at schedules a send up to 30 days ahead. Scheduled messages appear in the dashboard’s Messages → Scheduled tab, where they can be rescheduled or canceled until they send. Scheduling counts against the free tier’s daily cap on the day it’s accepted; the monthly quota is charged when the message actually sends.
  • scheduled_at accepts an ISO 8601 datetime string. Include an explicit timezone offset or Z (e.g. 2026-07-10T14:30:00Z or 2026-07-10T09:30:00-05:00). A bare local time without an offset is interpreted as UTC. It must be in the future and at most 30 days ahead.
  • On Cloudflare Workers a POSTBOI_TOKEN binding is read automatically. See Cloudflare Workers. Pass new Postboi({ token }) only to override it.
  • The token can be revoked and reissued any time from the dashboard’s API keys panel.