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, with 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
Name your provider. Like SMS, WhatsApp never infers one from credentials, because a wrong guess is a billable message to a real handset and Twilio’s credentials are shared with every other Twilio product you might be using:
Twilio reuses your Twilio SMS credentials and the same Message resource, and 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, with 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, so 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.
Delivery receipts and replies
Both providers report back, in the same normalized events as email: channel is "whatsapp", the number is in phone (never email), a read
receipt is opened because it is the same fact as an email open, and a message Meta or
Twilio couldn’t deliver is failed with the provider’s code and words in bounce.detail.
Twilio is polled, because its status callbacks are set per message at send time. poll({ provider: "twilio" }) covers SMS and WhatsApp in one row and needs no public
endpoint. See polling.
Meta pushes a real webhook, so it’s receive() like an email provider.
Two values from the app dashboard make it work: the app secret (Basic Settings) signs
every delivery as X-Hub-Signature-256, and a verify token you make up is what Meta
presents when it checks the endpoint is yours before subscribing it: a GET that webhook() answers, so route both methods to the same handler. Name the provider: the
zero-config default is your email provider (a project without one falls through to POSTBOI_WHATSAPP_PROVIDER=meta on its own).
Subscribe the app to the messages field of the WhatsApp Business Account, and one endpoint hears about every number the account owns. What arrives:
Reactions, system notices and deleted statuses aren’t delivery events and produce
nothing. A custom adapter is the route to them if you
need one. And because a received is the moment the customer service window opens,
it’s also the signal that free-form message sends to that number will deliver for the
next 24 hours.
Two honest caveats. The suppression call in the example is the Postboi provider’s; with another email provider, write the
number wherever you keep opt-outs instead. And phone is Meta’s wa_id with a + in
front, which is the E.164 number everywhere except Mexico and Argentina, where WhatsApp
inserts a digit after the country code (+52 1 …, +54 9 …). The send response’s contacts[].wa_id is the same form, so match on that rather than the number you dialled.
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.
Runnable examples: examples/scripts/whatsapp.ts covers both shapes: a free-form message inside the window and a template outside it.
The framework apps’ POST /notify route sends WhatsApp alongside SMS and chat; see the SvelteKit app.