WhatsApp Meta
Send WhatsApp messages via Meta's Cloud API
The WhatsApp Meta transport ships in @betternotify/whatsapp and calls the Meta WhatsApp Cloud API via fetch. No SDK dependency, no setup beyond Node 22+ or Bun.
It covers all ten WhatsApp message types (including Meta-approved templates) and two media delivery modes: URL passthrough and binary upload.
Install
npm install @betternotify/whatsappUsage
import { whatsappMetaTransport } from '@betternotify/whatsapp/transports';
const transport = whatsappMetaTransport({
accessToken: process.env.WHATSAPP_META_ACCESS_TOKEN,
phoneNumberId: process.env.WHATSAPP_META_PHONE_NUMBER_ID,
});Pass it to createClient via transportsByChannel:
import { createClient } from '@betternotify/core';
import { whatsappChannel } from '@betternotify/whatsapp';
import { whatsappMetaTransport } from '@betternotify/whatsapp/transports';
const notify = createClient({
catalog,
transportsByChannel: {
whatsapp: whatsappMetaTransport({
accessToken: process.env.WHATSAPP_META_ACCESS_TOKEN,
phoneNumberId: process.env.WHATSAPP_META_PHONE_NUMBER_ID,
}),
},
});Options
Prop
Type
Media delivery modes
Media messages (image, video, audio, document) support two modes:
URL mode
Set url on the rendered message. Meta fetches the file from your server.
rpc.whatsapp().image()
.url(({ input }) => input.photoUrl)
.caption('Check this out')Requirements:
- URL must be publicly reachable over HTTPS
- Server must respond with correct
Content-Typeheader - Meta caches media at the same URL for 10 minutes
Buffer mode
Set data (Buffer/Uint8Array) and mimeType on the rendered message. The transport uploads the binary to Meta's media API, then sends the message referencing the returned media ID.
rpc.whatsapp().document()
.data(({ input }) => input.pdfBuffer)
.mimeType('application/pdf')
.filename(({ input }) => `invoice-${input.orderId}.pdf`)Supported media formats
| Type | Formats | Max size |
|---|---|---|
| Image | image/jpeg, image/png | 5 MB |
| Video | video/mp4, video/3gpp (H.264 + AAC only) | 16 MB |
| Audio | audio/aac, audio/mp4, audio/mpeg, audio/amr, audio/ogg; codecs=opus | 16 MB |
| Document | Any valid MIME type | 100 MB |
The mimeType field provides autocomplete for all supported types via the WhatsAppMimeType type.
Templates
Use rpc.whatsapp().template() for Meta-approved business templates. The transport maps the rendered shape to Meta's type: 'template' payload:
{
"messaging_product": "whatsapp",
"to": "+5511999999999",
"type": "template",
"template": {
"name": "order_shipped",
"language": { "code": "pt_BR" },
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "ORD-1234" },
{ "type": "text", "text": "BR987654321" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "BR987654321" }]
}
]
}
}The components array is passed through verbatim. Component shapes are typed via WhatsAppTemplateComponent and match Meta's wire format:
headerparameters:text,image,video,document,locationbodyparameters:text,currency,date_timebuttonparameters:text(forurlandcopy_codesub_types) orpayload(forquick_reply)
Templates must be registered and approved in the Meta Business Manager before use. Parameter mismatches against the registered template surface as VALIDATION provider errors with codes 132000–132012 (see Error handling).
Reference
- Send message templates guide — Cloud API request shape and walkthrough.
- Template components reference — every header, body, and button parameter type.
- Manage message templates — create, edit, and submit templates for Meta approval.
- Cloud API messages reference — full
templateobject schema. - Error codes — full Meta error code reference, including the 132000-series template errors.
Verifying credentials
Call verify() at startup to confirm the access token and phone number ID are valid:
const transport = whatsappMetaTransport({
accessToken: process.env.WHATSAPP_META_ACCESS_TOKEN,
phoneNumberId: process.env.WHATSAPP_META_PHONE_NUMBER_ID,
});
const { ok, details } = await transport.verify();
if (!ok) {
throw new Error('Invalid WhatsApp credentials');
}
console.log('Phone:', details);
// { verified_name: 'My Business', display_phone_number: '+55...', quality_rating: 'GREEN' }Error handling
Meta API errors are mapped to BetterNotify error codes:
| Meta code | BetterNotify code | Retriable | Meaning |
|---|---|---|---|
| 190, 200, 4 | CONFIG | No | Authentication / permission failure |
| 130429, 131056 | RATE_LIMITED | Yes | Rate limit (throughput or per-user) |
| 131026, 131047, 131051, 131009, 100 | VALIDATION | No | Invalid message parameter |
| 132000, 132001, 132005, 132007, 132012 | VALIDATION | No | Template parameter mismatch, missing template, or policy violation |
| Other | PROVIDER | Yes | Unknown provider error |
A pre-flight VALIDATION error is also returned (without contacting Meta) when a media message is sent with neither url nor data, or when an interactive message has both buttons and sections or neither.
import { NotifyRpcProviderError } from '@betternotify/core';
import { isWhatsappRetriable } from '@betternotify/whatsapp';
try {
await notify.orderConfirm.send({ to, input });
} catch (err) {
if (err instanceof NotifyRpcProviderError) {
console.error(err.code, err.providerCode, err.retriable);
}
}
// Or use the helper
const shouldRetry = isWhatsappRetriable(err);API version upgrades
The baseUrl option includes the API version. When Meta releases a new version, override it:
const transport = whatsappMetaTransport({
accessToken: '...',
phoneNumberId: '...',
baseUrl: 'https://graph.facebook.com/v26.0',
});Contact name derivation
Meta requires first_name on contact messages even though their docs say only formatted_name is required. The transport automatically derives first_name and last_name from formatted_name (splits on first space) when they are not explicitly provided.
Mock transport
Use mockWhatsappTransport from the transports subpath for testing:
import { mockWhatsappTransport } from '@betternotify/whatsapp/transports';
const mock = mockWhatsappTransport();
// after sending...
console.log(mock.messages); // [{ action: 'text', to: '...', body: '...' }]