Skip to main content

Getting started

The Postboi provider

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


The zero-config way to send. No provider account, no DNS records, no card — run one command, authorise in the browser, and mail() works.

bunx postboi init
bunx postboi init
npx postboi init
npx postboi init
pnpm dlx postboi init
pnpm dlx postboi init
yarn 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.

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>",
})
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.

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 — but it isn’t an inbox, so set reply_to if you want responses:

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

Sending from your own domain

Verify a domain in the dashboard — add a domain, publish the three DKIM CNAME records it shows you, and hit Check. 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>",
})
await mail({
	from: "hello@yourdomain.com",
	to: "someone@example.com",
	subject: "Hello",
	body: "<p>From your own domain</p>",
})

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.
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.

Limits

Plan Included Daily cap Overage
Free 3,000/mo 100/day — (hard)
Starter 20,000/mo £0.40/1k
Pro 100,000/mo £0.35/1k
Scale 500,000/mo £0.30/1k

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:

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
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.

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.
  • In runtimes without ambient env vars (e.g. Cloudflare Workers), pass the token explicitly: new Postboi({ token }).
  • The token can be revoked and reissued any time from the dashboard’s API keys panel.