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