to is keyed by channel, always. Nothing is inferred from the shape of a value — an
address is only ever used on the channel you named it under.
Two modes
Fan out (the default) attempts every channel in to, concurrently. Each gets its own
result, so one failing never loses the others.
Fall back (channels) walks the list in order and stops at the first success —
which is what you want for a code or an alert that only needs to arrive once.
Which chat platform the chat leg uses
A to.chat that is a recognisable Slack or Discord webhook URL names its own platform —
that’s the whole setup, nothing else required. Otherwise the platform comes from POSTBOI_CHAT_PROVIDER or chat.provider in the config, and you should set one.
It is deliberately not inferred from a lone SLACK_WEBHOOK_URL, DISCORD_WEBHOOK_URL, TEAMS_WEBHOOK_URL or TELEGRAM_BOT_TOKEN. Those are the names every CI notification
action already sets, so inferring from them would quietly post your application’s messages
into somebody’s build-notification channel. The per-platform slack(), discord(), teams() and telegram() functions are unaffected — they never ask which
platform you meant.
Cheapest first
channels: "cheapest" uses the built-in order — push → chat → email → whatsapp → sms —
narrowed to the channels you actually have an address for.
That ordering is worth having because the spread isn’t marginal, it’s total. Push and chat cost nothing per message. Email is fractions of a penny. An SMS into Western Europe is 2.8p or more, and can exceed 7p. Preferring a cheaper channel doesn’t shave a percentage off — it saves the entire cost of the message.
Nobody else will do this for you, either: a hosted orchestrator meters the fan-out itself, and no SMS vendor is going to route you to a channel it doesn’t bill for.
Reading the result
send() only rejects when to names no reachable channel at all. Anything else
resolves, because a partial delivery is information you need rather than an exception to
catch. Every failure carries the channel it came from, so you never have to work out which
leg broke.
Content
Shared fields map onto each channel’s natural shape:
So the simplest useful call is one string:
When only message is given, email uses it as the body too rather than sending an empty one.
Per-channel overrides
Where the copy genuinely differs — and it usually does, because SMS is billed by the character — override just that channel:
Hooks fire per channel
Each leg runs through the hooks for its own channel, so a before.send sees three
separate calls for a three-channel fan-out — each with its own ctx.channel. That’s what
you want for suppression: skipping the SMS leg shouldn’t skip the email.
Runnable example: examples/scripts/notify.ts runs both
modes against real providers — the fan-out, and the cheapest-first chain — printing which
leg delivered.