Five channels, one tiny API. As of today sms(), whatsapp(), push(), slack(), discord(), teams() and telegram() are live alongside mail(), and they all work the same way:
the provider comes from your environment, hooks run around every send, and failures throw one
normalised error, whoever you route through.
import { sms } from "postboi"
await sms({ to: "+44 7788 223344", message: "Your code is 4291" })No SDK per vendor, no new shape to learn per channel. Learn one and you know the other four.
The new channels
SMS ships with The SMS Works (UK-native, bills only for delivered messages), Twilio and Amazon SNS. Numbers are normalised to E.164 and never guessed: anything ambiguous throws instead of texting a stranger in the wrong country. Segments are counted before your provider bills them, GSM-7 and UCS-2 alike, so the smart quote that silently doubles a bill isn't silent any more.
WhatsApp is template-first, because WhatsApp itself is. Free-form text only
delivers within 24 hours of the user's last reply, and most transactional messages happen outside
that window. So template sits beside message as a first-class field, via Twilio
or Meta's Cloud API:
import { whatsapp } from "postboi"
await whatsapp({
to: "+44 7788 223344",
template: "order_shipped",
variables: { name: "Ada", tracking: "AB123" }
})Push is the only channel where delivery costs nothing. Web Push has no vendor at all: the browser picks the push service and the subscription is the address. The payload encryption is our own WebCrypto implementation of RFC 8291, verified byte for byte against the spec's published test vector, and it runs unchanged on Node, Bun and Cloudflare Workers.
Native apps get the same call and three providers behind it, because "push to a phone" is really
three different transports wearing one name. FCM reaches Android — not as the
recommended route but as the only one, since push there goes through Google Play Services and
nothing else can touch that transport. APNs reaches Apple directly, with a .p8 key and no Firebase in the middle. And Huawei Push Kit reaches the
phones sold without Play Services since 2020, which FCM cannot reach at all — on those handsets it doesn't
fail loudly, it simply never arrives.
Direct APNs is the one most libraries skip, and the reason is duller than it looks: APNs refuses
HTTP/1.1, and Node's built-in fetch only speaks HTTP/1.1, so the obvious call dies
with a parser error before Apple ever sees it. We send over node:http2 on Node and
Bun, and over the global fetch on Workers and Deno where it already negotiates HTTP/2.
Nothing to configure, and still no dependencies.
bunx postboi init --push takes the tedium out of the Apple setup too. Apple names the
key it hands you AuthKey_<KEYID>.p8 and shows it exactly once, so we go looking for
it in your downloads and read the key ID straight out of the filename. Then we check the credentials
against APNs before writing anything — by sending to a device token that can't exist, which is enough
to tell you whether the key, the team and the bundle ID were all accepted.
Chat reaches Slack, Discord, Microsoft Teams and Telegram with a webhook URL as the only credential. Teams gets a proper Adaptive Card, because Microsoft switched off the old connector URLs in May 2026 and a dead webhook that still looks plausible is the worst kind of dead.
One call, every channel
The headliner is send(). Give it every address you have for someone and it fans out
concurrently, one result per channel. Or give it a channel order and it stops at the first
success, which is what you want for a login code that only needs to arrive once:
import { send } from "postboi"
const result = await send({
to: { push: subscription, sms: "+44 7788 223344" },
channels: "cheapest",
message: "Your code is 4291"
})
result.delivered // "push", and the SMS was never sent or billedchannels: "cheapest" tries push, then chat, then email, then WhatsApp, then SMS. That
ordering matters because the spread is total, not marginal: push and chat are free, email is
fractions of a penny, and an SMS into Western Europe is 3p or more. Stopping early doesn't shave a
percentage off, it saves the whole message. And the fan-out runs in your process: there's no hosted orchestrator in the send path and nobody
metering the routing.
One config file covers the lot, which is most of the reason to have one. A single error hook now observes every channel:
import { config } from "postboi"
export default config({
hooks: {
on: {
// One error hook covers email, SMS, WhatsApp, push and chat
error: (ctx) => Sentry.captureException(ctx.error)
}
}
})Development can't cost you money
In development, texts and WhatsApp messages are captured and logged, never sent, even with a fully configured provider. This is stricter than email on purpose. A stray email is embarrassing. A stray text costs money, reaches a real handset, and cannot be recalled.
postboi: development, texts are logged, not sent.
postboi (mock sms): +447788223344
from: POSTBOI
cost: 1 segment (gsm7)
Your code is 4291When you genuinely need real delivery locally, opt out explicitly with POSTBOI_SMS_DEV=send or dev: { sms: false } in your config. The
safe path is the one you get by doing nothing.
What it costs
The library is free and open source, and the channels are bring-your-own-provider: your Twilio, Meta or SMS Works account bills you at cost and Postboi adds nothing on top. No per-notification fee, no per-subscriber fee, and never a per-contact fee. Hosted notification platforms meter the orchestration layer. We made it a library instead, so there's nothing to meter. Here's how that compares.
Get started
Each channel has its own one-command setup, with providers ranked for where you're sending:
bunx postboi init --smsThen read the docs for SMS, WhatsApp, push, Slack and friends and the multi-channel send().