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

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, 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 is a single recipient, given either as a phone number or as a business-scoped user 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. One message goes to one recipient; there is no recipient array and no batch send, so reach many people with 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.

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: on a Bird-managed template it's 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.
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: 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.
Contoh kode
{
  "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, so if correlation has to be reliable, put your own reference in metadata on the send rather than depending on this field.
A message can only be quoted for a limited window after it was sent; a message this workspace does not hold, including one too old for that window, is refused with E15057. A message this workspace does hold is refused with E15058 if it never reached WhatsApp yet, or if it belongs to a different conversation than this send's to and from. Quoting works on a template send and a free-form send alike.
Contoh kode
{
  "to": "+16505551234",
  "from": "+13124495648",
  "in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
  "text": { "body": "Yes, that slot is still free." }
}

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
tostringyesOne recipient per message: an E.164 phone number, or a business-scoped user ID, which no one-time-passcode template accepts
fromstring (E.164)no**Omit for a Bird-managed template, which picks its own sender; 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.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
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
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, 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 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, 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 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, and Meta skips that receipt when the recipient already has the chat open, reporting only the read. Such a message carries no Meta share. See whatsapp.delivered can be skipped.
WhatsApp events do not carry cost. To read either component, read the message back with GET /v1/whatsapp/messages/{id}.

Next steps