Skip to main content

Channels

WhatsApp

Send WhatsApp messages with whatsapp() — templates that deliver anytime, free-form text inside the 24-hour window.


import { whatsapp } from "postboi"

await whatsapp({
	to: "+447788223344",
	template: "order_shipped",
	variables: { name: "Ada", tracking: "AB123" },
})
import { whatsapp } from "postboi"

await whatsapp({
	to: "+447788223344",
	template: "order_shipped",
	variables: { name: "Ada", tracking: "AB123" },
})

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.

bunx postboi init --whatsapp
bunx postboi init --whatsapp
npx postboi init --whatsapp
npx postboi init --whatsapp
pnpm dlx postboi init --whatsapp
pnpm dlx postboi init --whatsapp
yarn dlx postboi init --whatsapp
yarn dlx postboi init --whatsapp

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.

// Inside the window (the user messaged you recently) — free-form works:
await whatsapp({ to: "+447788223344", message: "Thanks — on its way!" })

// Anytime — a template approved with Meta, filled with variables:
await whatsapp({
	to: "+447788223344",
	template: "order_shipped",
	variables: { name: "Ada", tracking: "AB123" },
})
// Inside the window (the user messaged you recently) — free-form works:
await whatsapp({ to: "+447788223344", message: "Thanks — on its way!" })

// Anytime — a template approved with Meta, filled with variables:
await whatsapp({
	to: "+447788223344",
	template: "order_shipped",
	variables: { name: "Ada", tracking: "AB123" },
})

A free-form send outside the window fails with code: "outside_window", and the check hangs off whatsapp itself — no extra import:

import { whatsapp } from "postboi"

try {
	await whatsapp({ to, message })
} catch (error) {
	if (!whatsapp.closed(error)) throw error
	await whatsapp({ to, template: "re_engage", variables: { name } })
}
import { whatsapp } from "postboi"

try {
	await whatsapp({ to, message })
} catch (error) {
	if (!whatsapp.closed(error)) throw error
	await whatsapp({ to, template: "re_engage", variables: { name } })
}

(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

Provider Import Templates are
Twilio postboi/whatsapp-twilio Content SIDs (HX…), or their names
Meta Cloud API postboi/whatsapp-meta Approved names + language code

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.

# .env
POSTBOI_WHATSAPP_PROVIDER=twilio
TWILIO_ACCOUNT_SID=AC…
TWILIO_AUTH_TOKEN=
# .env
POSTBOI_WHATSAPP_PROVIDER=twilio
TWILIO_ACCOUNT_SID=AC…
TWILIO_AUTH_TOKEN=

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.

POSTBOI_WHATSAPP_PROVIDER=meta
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=123456789
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321   # optional — types your template names
POSTBOI_WHATSAPP_PROVIDER=meta
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=123456789
WHATSAPP_BUSINESS_ACCOUNT_ID=987654321   # optional — types your template names

Template variables

Named keys for templates approved with named parameters, numeric keys for positional ones:

variables: { name: "Ada", tracking: "AB123" } // {{name}}, {{tracking}}
variables: { 1: "Ada", 2: "AB123" } // {{1}}, {{2}}
variables: { name: "Ada", tracking: "AB123" } // {{name}}, {{tracking}}
variables: { 1: "Ada", 2: "AB123" } // {{1}}, {{2}}

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:

await whatsapp({
	to,
	template: "order_shipped",
	header: "#1234", // the header's one variable
	variables: { name: "Ada" }, // the body
	buttons: ["orders/1234"], // one entry per dynamic button, in order
})
await whatsapp({
	to,
	template: "order_shipped",
	header: "#1234", // the header's one variable
	variables: { name: "Ada" }, // the body
	buttons: ["orders/1234"], // one entry per dynamic button, in order
})

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:

header: { membershiptype: "Gold" },
buttons: [{ promo: "summer_2025" }],
header: { membershiptype: "Gold" },
buttons: [{ promo: "summer_2025" }],

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:

await whatsapp({ to, template: "order_shiped" })
// ^ Type error: not one of your approved templates.
//   Run `bunx postboi sync` to regenerate.
await whatsapp({ to, template: "order_shiped" })
// ^ Type error: not one of your approved templates.
//   Run `bunx postboi sync` to regenerate.

It reads each template’s placeholders too, so variables knows what that template takes — including that it takes them at all:

await whatsapp({ to, template: "order_shipped", variables: { name: "Ada" } })
//                                              ^ Type error: `tracking` is missing

await whatsapp({ to, template: "order_shipped" })
// ^ Type error: this template needs variables
await whatsapp({ to, template: "order_shipped", variables: { name: "Ada" } })
//                                              ^ Type error: `tracking` is missing

await whatsapp({ to, template: "order_shipped" })
// ^ Type error: this template needs variables

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:

POSTBOI_WHATSAPP_DEV=send
POSTBOI_WHATSAPP_DEV=send
// or, permanently, in postboi.config.ts
export default config({ dev: { whatsapp: false } })
// or, permanently, in postboi.config.ts
export default config({ dev: { whatsapp: false } })

The mock can also simulate the window for tests:

import MockWhatsapp from "postboi/whatsapp-mock"

const wa = new MockWhatsapp({ outside_window: true })
await wa.send({ to: "+447788223344", message: "hi" }) // rejects: outside_window
await wa.send({ to: "+447788223344", template: "order_shipped" }) // delivers
import MockWhatsapp from "postboi/whatsapp-mock"

const wa = new MockWhatsapp({ outside_window: true })
await wa.send({ to: "+447788223344", message: "hi" }) // rejects: outside_window
await wa.send({ to: "+447788223344", template: "order_shipped" }) // delivers

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:

await send({
	to: { whatsapp: "+447788223344", sms: "+447788223344" },
	channels: "cheapest",
	message: "Your code is 4291",
	whatsapp: { template: "login_code", variables: { 1: "4291" } },
})
await send({
	to: { whatsapp: "+447788223344", sms: "+447788223344" },
	channels: "cheapest",
	message: "Your code is 4291",
	whatsapp: { template: "login_code", variables: { 1: "4291" } },
})

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.