The zero-config way to send. No provider account, no DNS records, no card: run one
command, authorise in the browser, and mail() works.
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:
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:
- 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.jsonname seeds the slug:@acme/mail-sitesends frommail-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 â thatâs 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:
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.receivedand each reply arrives as a normalizedreceivedevent â 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:
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:
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:
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.
synconly writes keys your environment is missing; a deliberate local override is never clobbered.postboi env pull --forceis the explicit way to take the teamâs values wholesale. POSTBOI_TOKENnever syncs. Itâs per developer, and itâs the credential that unlocks the rest â 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:
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.
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:
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.
initadds 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),
fromfalls back to plainstring: builds and deploys never fail because the types are missing.syncitself is a quiet no-op without aPOSTBOI_TOKENand always exits 0, so itâs safe anywhere. - Theyâre a snapshot. Re-run
bunx postboi syncafter 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.
Limits
The free tier stops at its caps; paid tiers keep sending and meter the overage. Every
plan has a burst rate limit. When a limit is hit, mail() throws a PostboiError with a
machine-readable code:
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:
And a scheduled message can be moved (until it sends). mail.messages.reschedule takes the same formats as scheduled_at:
Batching & idempotency
Personalized batches go out as one request to the batch endpoint (up to 100 recipients per call) instead of one per recipient:
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:
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:
{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:
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 â 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/dataare now the contactâs, shared across its lists (last write wins) rather than stored per list; the per-list status enum issubscribed | pending | unsubscribed(bounced/complained are suppressions, not a status); andmail.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:
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:
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} â put it 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:
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:
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:
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:
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_atschedules 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_ataccepts an ISO 8601 datetime string. Include an explicit timezone offset orZ(e.g.2026-07-10T14:30:00Zor2026-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_TOKENbinding is read automatically â see Cloudflare Workers. Passnew Postboi({ token })only to override it. - The token can be revoked and reissued any time from the dashboardâs API keys panel.