Same shape as mail() and sms() — zero-config resolution, hooks, normalized errors — with one constraint the others don’t have,
and it shapes everything: the 24-hour customer service window.
The 24-hour window
A business may send free-form text only within 24 hours of the user’s last inbound
message. Outside that window — which is where most transactional sends happen — only pre-approved templates deliver. That’s why template sits beside message as a
first-class field rather than a provider option: template-only is the normal case, not the
edge case.
A free-form send outside the window fails with code: "outside_window", and the check
hangs off whatsapp itself — no extra import:
(Holding a provider instance directly? The same check is WhatsappProvider.is_outside_window().)
Exactly one of message or template per send — passing both is rejected rather than
guessed at, because a template’s content is fixed at approval time.
Providers
Twilio reuses your Twilio SMS credentials and the same Message resource — addresses get
the whatsapp: prefix added for you. Templates are created in the Content Template Builder
and addressed by their HX… SID — though once they’ve been synced you can use the friendly name instead, the same as on Meta.
Meta’s Cloud API is the direct route — no platform fee on top of Meta’s own pricing,
at the cost of Business verification. The sender is the phone_number_id from your app
dashboard, and templates are addressed by the name they were approved under plus a language
code (language, default "en") that must match an approved translation.
Template variables
Named keys for templates approved with named parameters, numeric keys for positional ones:
Which of the two a template uses is fixed when it’s approved and applies to the whole template, so the keys you write are really you saying which kind it is.
variables fills the template’s body. A placeholder in the header or in a button’s URL
is its own field, because Meta sends each as a separate component. Those hold one value
each, so they take it bare:
A named template’s header placeholder has a name of its own, unrelated to the body’s, and
nowhere else to go — so those take the map form instead, and a send that omits the name
comes back as error 132000:
Twilio numbers every placeholder in a single namespace, so there they all go in variables and header/buttons are ignored.
Typed template names
A misspelled template comes back from the platform as a failed send, which is a slow way to
find a typo. bunx postboi init --whatsapp and bunx postboi sync read your approved
templates from Meta or Twilio and narrow template to them, exactly the way type-safe from narrows your sending addresses:
It reads each template’s placeholders too, so variables knows what that template
takes — including that it takes them at all:
The templates live on the platform, not on your Postboi account, so the sync runs against
Meta or Twilio with the credentials already in your env — no Postboi account needed. Meta
needs one extra id to list them, WHATSAPP_BUSINESS_ACCOUNT_ID, which sits beside the phone
number id in the API Setup panel; Twilio needs nothing you don’t already have.
On Twilio this also earns you names. Twilio sends a ContentSid, so the sync bakes the
name→SID map alongside the types and the provider resolves it — the same template: "order_shipped" works on both platforms, and a raw HX… still goes through
untouched.
Like the from types, this lives inside node_modules (nothing to commit) and is entirely
optional: with nothing generated, template accepts any string and variables any record.
A raw HX… stays valid whatever’s been generated, and a template whose body the sync
couldn’t read keeps accepting any variables rather than rejecting them — a stale list should
never fail code that works. Re-run sync after getting a new template approved; init adds a prepare script so installs restore it.
Development sends nothing
Like SMS and for the same reason — a template send costs real money and reaches a real handset with no recall — WhatsApp messages are captured and logged, never sent in development, even with a configured provider. Opt out explicitly when you need real delivery:
The mock can also simulate the window for tests:
In a fallback chain
send() slots WhatsApp between email and SMS in its "cheapest" order, and an outside_window failure is just a signal to advance — so a code or alert falls through to
SMS rather than failing:
The whatsapp override carries the template so that leg stays deliverable outside the
window, while the plain message rides the channels that can always carry it.
Phone numbers
The same E.164 rules as SMS: international forms pass through,
national forms need a default country (whatsapp.default.country or POSTBOI_WHATSAPP_COUNTRY), and anything ambiguous throws rather than guesses.