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 type of content: a pre-approved template, or a service message carrying text, an image, video, audio, a sticker, a document, a location, contact cards or something to tap. Bird returns 202 Accepted with a message ID and delivers asynchronously. Which of the two you can send depends on the customer service window. 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. Fill any variables the template declares through parameters.

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",
    parameters: { code: "123456" },
  },
});
console.log(msg.id, msg.status);

The customer service window

Which of the two you can send depends on one piece of state: whether the customer service window is open.

The contact opens the window by messaging or calling your business number, and it stays open for 24 hours, resetting every time they message you again. While it's open you can send a service message, meaning any free-form content: text, image, video, audio, sticker, document, location, contact cards, or interactive. Once it lapses, only a pre-approved template reaches them, and their reply to it reopens the window.

Bird tracks the window for you, so a service message sent into a closed one is refused before anything is created or charged: the request returns a 422 E15044 WhatsAppServiceWindowClosed. The check is best effort and fails open, so a 202 is not proof the window was actually open at dispatch; a window that lapses between accept and dispatch fails asynchronously, with service_window_expired on the message's last_error.

See the customer service window for the full lifecycle: what opens it, what resets it, and how it interacts with pricing.

Building up the payload

Recipient

to names one destination, given as a phone number, a business-scoped user ID, or a group ID. A phone number is 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. There is no recipient array and no batch send, so reaching many people who are not in one group means one call per recipient.

A business-scoped user ID such as US.13491208655302741918 addresses a contact whose phone number you do not have, which is how you reply to a contact who reached you without one. Two things change: the sending number has to belong to the same business portfolio the ID is scoped to, and a one-time-passcode template needs a phone number. A Bird-managed one is refused at accept with a 422 WhatsAppRecipientNotSupportedForTemplate; an authentication template your workspace authored is accepted and then fails, since Meta requires a phone number for it.

to takes one more form: a WhatsApp group ID such as wag_01krdgeqcxet5s7t44vh8rt9mg, which sends to every participant of that group chat. A group send omits from and reports its delivery across the group rather than against one recipient, so Sending to a WhatsApp group covers it on its own page.

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.
  • parameters: the values that fill the template's variables, as one object keyed by name (see Components and parameters). Omit it for a template that has no variables.
  • components: the same values in Meta's component shape, instead of parameters. A few templates take only this form.

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}}). Fill them through parameters, one object. The template version's variables lists the keys it takes and marks the optional ones. Each key is one of these:

  • a named variable's own name, such as order_id
  • a numbered variable's number, such as 1
  • header_url, header_filename or header_location for a media or location header, and header_url_1, header_url_2 and so on for a carousel card's header
  • button_url for a URL button's numbered suffix, and button_url_1, button_url_2 and so on for a carousel card's button. A named suffix takes its own name.
  • code for an authentication template's code, and for a copy-code button's value
  • an input that the language's parameter bindings read

Each value is a string, number or boolean. header_location takes an object with latitude and longitude. Leave out a media header to send the template's approved sample. A template imported from Meta has no sample, so it needs the media header's key. Keys the language doesn't use are ignored.

Code example
{
  "to": "+15551234567",
  "from": "+13124495648",
  "template": {
    "slug": "order_update",
    "language": "en",
    "parameters": { "order_id": "A-4192", "first_name": "Ava" }
  }
}

On the CLI, pass the object with --parameters:

Code example
bird whatsapp send --to +15551234567 --from +13124495648 \
  --template order_update --language en \
  --parameters '{"order_id":"A-4192","first_name":"Ava"}'

An authentication template whose body reads {{1}} is your verification code, and whose button copies the code, takes the code once as code. Bird fills both the body and the button with it:

Code example
{
  "parameters": { "code": "481920" }
}

A language whose bindings read contact data, such as bird.contact.first_name, can't be sent as a single message and returns E15091. Send it in a broadcast instead, where each recipient's contact fills it. A broadcast skips a recipient whose contact data leaves a required value empty. Send parameters or components, not both. Some templates accept only parameters: one whose language computes its values with parameter bindings, and one with named variables in a carousel card or a button URL.

Some templates take only components, because one key can't tell their slots apart. This happens when a template numbers {{1}} in more than one block, such as the header and the body. It also happens when one block has two url buttons with numbered suffixes, or a {{button_url}} variable beside a numbered url button. Sending such a template with parameters returns 422.

Fill a template by component. components is the alternative to parameters, and the only form for a template that one key can't address. Each component names a type (header, body, button, or carousel) and a parameters array. A carousel takes a cards array instead, one entry per approved card, in approval order. Each card holds its own components array of header, body and button entries. 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 on a Bird-managed template is dropped, since no Bird-managed template declares a header variable, but on a template your workspace authored it's forwarded, which is how a media-header utility or marketing template gets its image.

The same passcode template 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

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: a service message of any type, 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 types of number and how a number of your own is connected.

Service messages

Instead of template, carry exactly one of text, image, video, audio, sticker, document, location, contact_cards, or interactive. All nine are service messages, so they need an open customer service window. Every one of them also 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. An http URL is rejected outright. WhatsApp fetches the file itself, so a URL it cannot reach, one serving an unsupported type, or a file above the size limit for its type is accepted and then fails, with media_rejected on the message's last_error and WhatsApp's own reason in description. 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.
  • contact_cards: an array of up to five contacts shared in one message. Each card's name needs formatted_name plus at least one other part (first_name, last_name, middle_name, prefix, or suffix); phone_numbers, emails, urls, and addresses each take up to ten entries, and org and birthday (as YYYY-MM-DD) are optional. A phone_number in E.164 earns that card a button that opens a chat with it.
  • interactive: body text plus something to tap, in one of six types: reply buttons, a list menu, a link button, a media carousel, or a single button asking the recipient for their location or their phone number. Interactive messages covers the wire shape of each type, the replies a tap produces, and the limits.
Code example
{
  "to": "+16505551234",
  "from": "+13124495648",
  "text": { "body": "Your order shipped: https://example.com/track/A1B2C3", "preview_url": true }
}

A request carrying no content, or more than one type, is rejected with a 422.

Quoting a message

Set in_reply_to_message_id to a WhatsApp message ID to send your message as a reply to it, the way tapping reply in the WhatsApp app quotes a message. The recipient sees your message with the quoted one above it, and the field comes back on every read of the message.

It works the other way too: an inbound message that WhatsApp marks as a reply carries the quoted message's ID in the same field, which is how you tell which of your messages a reply answers. An inbound message WhatsApp does not mark carries no ID, and resolution can also miss. For reliable correlation, use explicit interactive reply identifiers with your application’s stored conversation or task state. Outgoing metadata stays on the outgoing record and is not automatically copied onto the reply.

The quote is resolved before the send is accepted, so a quote that cannot be rendered fails the request itself and nothing is created or charged. An id naming no message this workspace holds, or one older than the 15 days a message stays quotable, returns a 404 E15071 WhatsAppReferencedMessageNotFound. One naming a message that never reached WhatsApp, or a message from a different conversation than this send's to and from, returns a 422 E15072 WhatsAppMessageNotQuotable. If Bird cannot reach the store that answers the question, the send returns a 503 E15073 WhatsAppMessageLookupUnavailable, which is worth retrying. Quoting works on a template send and a free-form send alike.

Code example
{
  "to": "+16505551234",
  "from": "+13124495648",
  "in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
  "text": { "body": "Yes, that slot is still free." }
}

Tags and metadata

Three optional fields attach your own context to a message; all three 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).
  • reference: one string of 1 to 255 characters that you want to find the message by, such as an order or ticket number. ?reference= on the message list matches it exactly, case-sensitively, and several messages can share one. Set it only when you need to search on it, and put any other data in metadata.
Code example
{
  "tags": [{ "name": "campaign", "value": "order-confirmations" }],
  "metadata": { "order_id": "ord_8271" },
  "reference": "ord_8271"
}

Field reference

FieldTypeRequiredLimits / notes
tostringyesOne recipient per message: an E.164 phone number, a business-scoped user ID, which no one-time-passcode template accepts, or a WhatsApp group ID (wag_…), which sends to every participant of that group
fromstring (E.164)no**Omit for a Bird-managed template, which picks its own sender, and for a group send, which uses the group's own number; required for a service message 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.parametersobjectnoFills the template's variables by name, such as ref or code; at most 16 KB, and bird is reserved
template.componentsarraynoThe alternative to parameters, one entry per component: type is header, body, button or carousel, and a carousel takes cards instead of parameters
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
textobjectno**{body, preview_url?} plain-text service message (up to 4,096 characters); see Plain text
image, video, audio, sticker, documentobjectno**Media service message with url (and optional caption or filename where supported); see Service messages
locationobjectno**{latitude, longitude, name?, address?} pin; see Locations
contact_cardsarrayno**Up to 5 structured vCard contact objects; see Contact cards
interactiveobjectno**Body text plus one type of tappable content; a service message, so it needs an open service window. See Interactive messages
in_reply_to_message_idstringnoA WhatsApp message ID this workspace holds, quoted in the message you send; echoed on reads. See Quoting a message
tagsarraynoUp to 20 {name, value} labels; name ≤ 32 chars, value ≤ 64, names unique
referencestringno1–255 characters; exact match for ?reference= on the message list
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 service-message content field (text, image, video, audio, sticker, document, location, contact_cards, interactive); see Service 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, a parameter mismatch, or a service message sent into a closed customer service window (WhatsAppServiceWindowClosed). 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.

Receiving the reply

Inbound messages land on the same resource as outbound ones, and every one of them resets the service window. Receiving WhatsApp messages covers reading them over the API, fetching the media a contact sent, and the whatsapp.received webhook.

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 an applicable delivered or read 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.

Note: a service message carries no Meta share until September 30th 2026, and neither does a utility template delivered inside an open customer service window. From October 1st 2026 Meta charges for both, except inside a 72-hour free entry point window, and with 1,000 free service messages per business phone number per month: October 2026 pricing changes.

The two components are priced on different inputs. Bird's fee uses the category of the template you sent and the recipient's country, which comes from the phone number's country code or, on a send addressed to a business-scoped user ID, from the two-letter prefix on that ID. Meta's share uses the category Meta itself reports on the applicable receipt, which can differ from the template's: Meta can report authentication-international when its destination, business-location and eligibility rules apply. See WhatsApp authentication-international rates.

What cost reads depends on how far the message has traveled:

  • 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 an applicable delivered or read receipt, a successfully recorded Meta charge populates passthrough_amount, and amount reflects the recorded components.

A null component means no amount is recorded in that projection; it is not proof that the message was free. A component explicitly priced at zero 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 without reversing the observed message state. Your delivery is never held up by the second charge.

A message charged by Bird retains that outbound charge if delivery later fails. The Meta fee is processed from an applicable delivered or read callback when Meta reports regular pricing with a resolvable category and destination. Both callback paths use the same fee identity and rely on the billing service’s deduplication. Reconcile replayed receipts against billing records instead of treating the message projection as a permanent debit receipt. Service or free-entry pricing can make the Meta component zero; an unresolved component is not evidence that the message was free.

Use the billing ledger for financial reconciliation. Message cost fields are projections of the charges and can lag or remain incomplete. See WhatsApp metrics for the distinction between message observations and billing records.

WhatsApp events do not carry cost. To read either component, read the message back with GET /v1/whatsapp/messages/{id}.

Next steps