Documentation
Sign inGet started

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);

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):
Contoh kode
{
  "components": [
    { "type": "body", "parameters": [{ "type": "text", "text": "481920" }] },
    { "type": "button", "parameters": [{ "type": "text", "text": "481920" }] }
  ]
}

Category and sender

A template's category (authentication, utility, or marketing) determines how WhatsApp treats the message and, with the destination country, what it costs.
Who owns the sender decides whether you name it:
  • A Bird-managed template (its slug begins with bird_) sends from the number Bird keeps for that category, so omit from. Setting it returns a 422 WhatsAppSenderNotAllowed.
  • Anything else names its own sender in from: free-form content of any kind, and any template your workspace authored. The number must be one your workspace owns. Omitting it returns a 422 WhatsAppSenderRequired, and a number the workspace cannot send from returns a 422 WhatsAppSenderNotFound. An authored template also has to sit on the same WhatsApp Business Account as the number, or the send returns a 422 WhatsAppSenderWABAMismatch.
Phone number setup covers both kinds of number and how a number of your own is connected.

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, a number your workspace owns; Bird's managed numbers cannot carry it.
  • 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.
Contoh kode
{
  "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).
Contoh kode
{
  "tags": [{ "name": "campaign", "value": "order-confirmations" }],
  "metadata": { "order_id": "ord_8271" }
}

Field reference

FieldTypeRequiredLimits / notes
tostring (E.164)yesOne recipient per message
fromstring (E.164)no**Omit for a Bird-managed template, which picks its own sender; required for free-form content and for a template your workspace authored, and must be a number your workspace owns
template.slugstringno**A template slug your workspace can send; Bird-managed slugs begin with bird_
template.languagestringno*Template language tag (en, pt-BR); omit to send the template's default language
template.componentsarraynoFills the template's variables; component type is body or button
template.components[].parameters[].namestringno†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
tagsarraynoUp to 20 {name, value} labels; name ≤ 32 chars, value ≤ 64, names unique
metadataobjectnoArbitrary 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.

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 unfunded wallet is not one of them: the send is accepted, and the message ends rejected with insufficient_balance once Bird tries to charge it. 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. A message is charged in two steps, at two different moments, and the cost object on the message reports both:
FieldWhat it isWhen it lands
transaction_amountBird's fee for handling the sendWhen Bird processes the accepted send, before dispatch
passthrough_amountMeta's share of the message price, which Bird passes throughWhen the delivered receipt arrives
amountThe sum of the components priced so farGrows as each component lands
currency_codeYour organization wallet's currency, shared by both componentsWith the first component
Both amounts are decimal strings, net of tax.
The two components are priced on different inputs. Bird's fee uses the category of the template you sent and the recipient's country. Meta's share uses the category Meta itself reports on the delivery receipt, which can differ from the template's: an authentication template delivered to another country is priced by Meta as authentication-international. See WhatsApp authentication-international rates.
What cost reads depends on how far the message has travelled:
  • At the 202, cost is null. Nothing has been priced.
  • After processing, transaction_amount is set and amount equals it. passthrough_amount stays null.
  • After a delivered receipt, passthrough_amount fills in and amount grows to the sum.
A null component means it was never priced. A component priced at nothing reads "0.00000".
The two charges also fail differently. Bird's fee fails closed: when it cannot go through after the 202 because the wallet cannot cover the send or the route has no configured price, the message 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. Meta's share fails open: if the wallet is short or the rate is missing when the receipt arrives, the charge is skipped and the message stays delivered. Your delivery is never held up by the second charge.
A message that Bird charged and WhatsApp then could not deliver keeps its transaction_amount and never gets a passthrough_amount. There is one gap worth knowing: the second charge is triggered by the delivered receipt alone, so a message a recipient reads without WhatsApp ever reporting delivery carries no Meta share.
WhatsApp events do not carry cost. To read either component, read the message back with GET /v1/whatsapp/messages/{id}.

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