Sending WhatsApp messages
This guide covers the send endpoint, POST /v1/whatsapp/messages. You build one JSON payload with a recipient and a template; Bird returns 202 Accepted with a message ID and delivers asynchronously. Business-initiated WhatsApp is template-only: you send a pre-approved template, not free text. Each request sends one message to one recipient, and there is no batch endpoint.
A minimal send
The smallest valid payload is a to recipient and a template with its name. Include language unless the template has a single language, and fill any {{n}} variables the template declares through components.
curl
The snippet calls the US host; if your key starts with bk_eu1_, call https://eu1.platform.bird.com instead.
Przykład kodu
curl -X POST https://us1.platform.bird.com/v1/whatsapp/messages \
-H "Authorization: Bearer $BIRD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550100",
"template": {
"name": "bird_order_confirmation",
"language": "en",
"components": [
{ "type": "body", "parameters": [
{ "type": "text", "text": "A1B2C3D4" },
{ "type": "text", "text": "EUR 49.99" }
] }
]
}
}'TypeScript
Przykład kodu
npm install @messagebird/sdkThe SDK reads the region from your key, so you don't set a host:
Przykład kodu
import { BirdClient } from "@messagebird/sdk";
const bird = new BirdClient({ apiKey: process.env.BIRD_API_KEY! });
const msg = await bird.whatsapp.send({
to: "+14155550100",
template: {
name: "bird_order_confirmation",
language: "en",
components: [
{
type: "body",
parameters: [
{ type: "text", text: "A1B2C3D4" },
{ type: "text", text: "EUR 49.99" },
],
},
],
},
});
console.log(msg.id, msg.status); // "wam_…", "accepted"The 202 response is the accepted message: it echoes the resolved template with the values you filled in, and from names the Bird-managed number the message goes out on. There is no from field on the request; Bird selects the sender from the template's category.
Przykład kodu
{
"created_at": "2026-07-23T14:58:43.828Z",
"delivered_at": null,
"direction": "outbound",
"from": { "phone_number": "+13124495550" },
"id": "wam_01ky7qrtzme18862zq9bf0sby5",
"read_at": null,
"sent_at": null,
"status": "accepted",
"template": {
"category": "utility",
"components": [
{
"parameters": [
{ "text": "A1B2C3D4", "type": "text" },
{ "text": "EUR 49.99", "type": "text" }
],
"type": "body"
}
],
"language": "en",
"name": "bird_order_confirmation"
},
"to": { "phone_number": "+14155550100" }
}Building up the payload
Recipient
to is a single recipient in E.164 format: a leading +, country code, and subscriber number, e.g. +14155550100. Bird validates the number, so a value that cannot be a real, dialable number (wrong length, unassigned prefix) is rejected with a 422 WhatsAppInvalidRecipient before anything is charged. One message goes to one recipient; there is no recipient array and no batch send, so reach many people with one call per recipient.
Template
template names the pre-approved template to send:
- name (required): the template's name, e.g. bird_order_confirmation. It must match a template in your catalog (lowercase letters, digits, and underscores).
- language: the template's language tag, e.g. en or pt_BR. Omit it only when the template exists in a single language; when it is stocked in several, omitting language returns a 422 that names the available codes. The accepted message echoes the resolved language.
- components: the values that fill the template's {{n}} variables (see Components and parameters). Omit it for a template that has no variables.
Browse your templates, their languages, and a rendered preview of each on the Templates page.
Components and parameters
Templates carry numbered variables: {{1}}, {{2}}, and so on. You supply their values through components. Each component names a type (body or button) and a parameters array; each parameter is { "type": "text", "text": "..." }, applied in {{n}} order (the first parameter fills {{1}}). Only text parameters are supported today, and parameter counts must match the template's declared placeholders exactly, or the send returns a 422 WhatsAppTemplateParameterMismatch. A header component type also exists on the wire, but Bird manages header values itself, so a header entry supplied on a send is ignored.
For example, a one-time-passcode template whose body reads {{1}} is your verification code and whose button copies the code takes the code as both a body parameter and a button parameter:
Przykład kodu
{
"components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "481920" }] },
{ "type": "button", "parameters": [{ "type": "text", "text": "481920" }] }
]
}Category and sender
Category and sender are properties of the template, not of the send. A template's category (authentication, utility, or marketing) determines how WhatsApp treats the message, which sender number Bird uses, and, together with the destination country, what the message costs. There is no from field on the request.
Tags and metadata
Two optional fields attach your own context to a message; both come back on API reads and ride on every webhook event for the message:
- tags: up to 20 structured { "name": ..., "value": ... } labels for low-cardinality dimensions you filter and report by (a campaign, an experiment variant). Names and values take ASCII letters, digits, underscore, and hyphen; names are capped at 32 characters and unique within a send, values at 64. Filter the message list by tag (?tag=campaign or ?tag=campaign:launch-week), and the Metrics page breaks delivery down by tag.
- metadata: one arbitrary JSON object, up to 2 KB serialized, for per-send context you don't need as a filter dimension (an internal order ID, a session reference).
Przykład kodu
{
"tags": [{ "name": "campaign", "value": "order-confirmations" }],
"metadata": { "order_id": "ord_8271" }
}Field reference
| Field | Type | Required | Limits / notes |
|---|---|---|---|
| to | string (E.164) | yes | One recipient per message |
| template.name | string | yes | A template name from your catalog; lowercase letters, digits, underscores |
| template.language | string | no* | Template language tag (e.g. en, pt_BR); omit for single-language templates |
| template.components | array | no | Fills {{n}} variables; component type is body or button |
| tags | array | no | Up to 20 {name, value} labels; name ≤ 32 chars, value ≤ 64, names unique |
| metadata | object | no | Arbitrary JSON, up to 2 KB serialized |
* language is optional only when the template has a single language.
What isn't supported yet
The following are rejected today:
- Free-form (non-template) sends: WhatsApp sends are template-only in this release; a request without template fails with a 422.
- Non-text parameters: media, location, and other parameter types are reserved; only text is supported.
- Batch sending: there is no /v1/whatsapp/batches; send one message per call.
The async model: what 202 means
A successful send returns 202 Accepted with a message ID and status: accepted. The 202 is returned only after the send is durably accepted; it is never accepted and then silently dropped. Hard failures you can fix (an invalid recipient, an unknown template name or language, a parameter mismatch) fail immediately with a 422, and an organization whose wallet holds no balance at all is rejected up front. Actual delivery happens asynchronously: the message moves to sent when Bird hands it to WhatsApp, then to a terminal status (delivered or failed) when the receipt arrives, reported through events, webhooks, and the read endpoints. A read receipt is surfaced separately as a read_at timestamp and a whatsapp.read event, not as a status.
One privacy note: for authentication-category templates the API never returns the filled-in values. The 202 echo and every later read carry an empty components array for those messages, so a passcode never resurfaces.
Retrying safely
Send the Idempotency-Key header with a unique value per logical send, and retries become safe: if your first request succeeded but you never saw the response (timeout, dropped connection), replaying the same request with the same key returns the original result instead of sending, and charging for, a duplicate message. The replayed response carries an Idempotency-Replay header. See idempotency for key format and retention.
Cost and billing
WhatsApp is priced per message, keyed on the template's category and the recipient's country; see WhatsApp pricing. Bird charges your wallet when it processes the accepted send, before the message is dispatched to WhatsApp, and the charge stands whether or not the message is later delivered. When the charge cannot go through after the 202 (the wallet cannot cover the send, or the route has no configured price), the message ends failed with the error code insufficient_balance or price_not_found, and nothing is charged.
Next steps
- WhatsApp overview: the channel, the dashboard app, and where everything lives
- WhatsApp events: follow delivery per message, over the API or webhooks
- Templates: browse the catalog and read a template's variables
- Idempotency: safe retries with the Idempotency-Key header