Sending WhatsApp messages
This guide covers the send endpoint, POST /v1/whatsapp/messages. You build one JSON payload with a recipient and exactly one kind of content: a pre-approved template, or free-form text, image, video, audio, sticker, document or location. Bird returns 202 Accepted with a message ID and delivers asynchronously. A template is the only content WhatsApp delivers outside an open 24-hour customer service window; see Free-form messages below. 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 slug. Add language if you want a specific one; omitting it sends the template's default language, and fill any variables the template declares through components.
The curl call names the US host; if your key starts with bk_eu1_, call https://eu1.platform.bird.com instead. The SDKs read the region from your key, so they set no host.
const msg = await bird.whatsapp.send({
to: "+15551234567",
template: {
slug: "bird_otp",
components: [{ type: "body", parameters: [{ type: "text", text: "123456" }] }],
},
});
console.log(msg.id, msg.status);msg = client.whatsapp.send(
to="+31612345678",
template="bird_otp",
language="en",
components=[{"type": "body", "parameters": [{"type": "text", "text": "123456"}]}],
)
print(msg.id, msg.status)package main
import (
"context"
"fmt"
"log"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(option.WithAPIKey(os.Getenv("BIRD_API_KEY")))
if err != nil {
log.Fatal(err)
}
msg, err := client.Whatsapp.Send(context.Background(), bird.WhatsappSendParams{
To: "+15551234567",
Template: "bird_otp",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$message = $bird->whatsapp->send(
to: '+15551234567',
template: 'bird_otp',
language: 'en',
);
echo $message->getId(), ' ', $message->getStatus();bird whatsapp send \
--components '[{"parameters":[{"text":"1234","type":"text"}],"type":"body"},{"parameters":[{"text":"1234","type":"text"}],"type":"button"}]' \
--language en \
--template bird_otp \
--to +31612345678curl -X POST "https://{region}.platform.bird.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+31612345678",
"template": {
"slug": "bird_otp",
"language": "en",
"components": [
{
"type": "body",
"parameters": [
{
"type": "text",
"text": "1234"
}
]
},
{
"type": "button",
"parameters": [
{
"type": "text",
"text": "1234"
}
]
}
]
}
}'Building up the payload
Recipient
to is a single recipient in E.164 format: a leading +, country code, and subscriber number, such as +14155550100. We validate 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:
- slug (required): the template's slug, such as bird_order_confirmation. It must match a template in your catalog (lowercase letters, digits, and underscores).
- language: the template's language tag, such as en or pt-BR. Omit it to send the template's default language; naming a language the template doesn't have returns a 422 that lists the ones it does. The accepted message echoes the resolved language.
- components: the values that fill the template's 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 variables, named ({{ref}}, {{amount}}) or numbered ({{1}}, {{2}}). You supply their values through components. Each component names a type (body or button) and a parameters array. Each parameter names its own type (text, image, video, gif, document, or location) and carries the matching field: text a plain string, image/video/gif/document a public https url, and location a point on the map. A template with named parameters requires a name on every parameter, matching exactly the names the template declares (see Field reference). A positional template omits name and takes its values in {{n}} order instead, so the first parameter fills {{1}}. Either way, parameters that don't match what the template declares return a 422 WhatsAppTemplateParameterMismatch. A header component type also exists on the wire, but we manage header values ourselves, 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, positionally (no name):
Code example
{
"components": [
{ "type": "body", "parameters": [{ "type": "text", "text": "481920" }] },
{ "type": "button", "parameters": [{ "type": "text", "text": "481920" }] }
]
}Category and sender
For a template, category and sender are properties of the template rather than of the send. A template's category (authentication, utility, or marketing) determines how WhatsApp treats the message and which sender number we use. Together with the destination country, the category also determines what the message costs. Omit from on a template send; setting it there is rejected. Every free-form send (see below) requires from, a number your workspace owns.
Free-form messages
Instead of template, carry exactly one of text, image, video, audio, sticker, document, or location. Free-form content is deliverable only inside an open 24-hour customer service window. The contact opens that window by messaging or calling you, and it resets every time they do so again. We do not track the window ourselves, so a send outside one is accepted and then fails, with service_window_expired on the message's last_error. Every free-form send requires from: the number must be one your workspace owns.
- text: { "body": "..." }, up to 4096 characters. Add "preview_url": true to render a link preview for the first URL in body.
- image, video, audio, sticker, document: each takes a public https URL WhatsApp fetches at send time (url), so a signed URL has to outlive the send. image, video, and document also take an optional caption; document also takes an optional filename; audio takes an optional voice flag for a voice-note rendering.
- location: { "latitude": ..., "longitude": ... } (both required, decimal degrees) plus optional name and address.
Code example
{
"to": "+31612345678",
"from": "+13124495648",
"text": { "body": "Your order shipped: https://example.com/track/A1B2C3", "preview_url": true }
}A request carrying no content, or more than one kind, is rejected with a 422.
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).
Code example
{
"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 |
| from | string (E.164) | no** | Omit for a template; required for free-form content, and must be a number your workspace owns |
| template.slug | string | no** | A template slug from your catalog; lowercase letters, digits, underscores |
| template.language | string | no* | Template language tag (en, pt-BR); omit to send the template's default language |
| template.components | array | no | Fills the template's variables; component type is body or button |
| template.components[].parameters[].name | string | no† | The placeholder this value fills, such as ref; required and must match the template's declared names for a named-parameter template, omitted for a positional one |
| 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; omitting it sends the template's default language.
† name is required on every parameter for a named-parameter template. Omit it for a positional one. See Components and parameters.
** Carry exactly one of template or a free-form content field (text, image, video, audio, sticker, document, location); see Free-form messages.
What isn't supported yet
The following is rejected today:
- 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 fail immediately with a 422: an invalid recipient, an unknown template slug or language, or a parameter mismatch. An organization whose wallet holds no balance at all is rejected up front. Actual delivery happens asynchronously: the message moves to sent when we hand 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 rather than 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 it 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. We charge your wallet when we process the accepted send, before the message is dispatched to WhatsApp, and the charge stands whether or not the message is later delivered. The charge can still fail after the 202, when the wallet cannot cover the send or the route has no configured price. The message then ends rejected with the error code insufficient_balance or price_not_found, and nothing is charged. A rejected message never reached WhatsApp, which is what separates it from failed.
In the destinations that have one, authentication templates are always charged Meta's higher authentication-international rate, because the sending WhatsApp Business Account is Bird's. See WhatsApp authentication-international rates.
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