Receiving WhatsApp messages
Inbound messages land on the same resource as outbound ones, so there is no separate inbox endpoint to poll. Read them with the message list in the dashboard, or over the API with GET /v1/whatsapp/messages/{id} after filtering the list to inbound.
Every inbound message resets the customer service window to a fresh 24 hours, which is what makes a free-form reply of your own deliverable.
What an inbound message carries
Every inbound message shares one envelope: an id, direction: "inbound", the contact in from, your own number in to, a status of received, and a created_at. Exactly one content field sits alongside it, naming what the contact sent:
Exemple de code
{
"id": "wam_01kya19eknftrs2s6p82asmvnh",
"direction": "inbound",
"from": { "phone_number": "+14155550100" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"text": { "body": "Is my order out for delivery yet?" },
"created_at": "2026-08-25T09:04:11Z"
}One of these content fields carries what they sent:
| Field | What an inbound one carries |
|---|---|
| text | body, the message the contact typed |
| image | An id, url, mime_type, and any caption |
| video | The same media fields, plus any caption |
| audio | The same media fields; no caption exists on this arm |
| sticker | The same media fields, plus animated |
| document | The same media fields, plus any filename and caption |
| location | latitude and longitude, and sometimes name, address, or a url |
| interactive_reply | The slug and text of the button or row the contact tapped |
| contact_cards | One or more shared contact cards. Inbound only |
unsupported takes their place for a message type the API does not model.
An audio, video or document arrives the same way an image does, as a reference plus whatever metadata the arm carries:
Exemple de code
{
"id": "wam_01kyb3q8ndvt6r2h5k9wxc4pfa",
"direction": "inbound",
"from": { "phone_number": "+14155550100", "bsuid": "US.13491208655302741918" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"audio": {
"id": "waf_01kyb2m4xq7whs0d8n3prv6tez",
"url": "https://platform.bird.com/v1/whatsapp/messages/wam_01kyb3q8ndvt6r2h5k9wxc4pfa/media/waf_01kyb2m4xq7whs0d8n3prv6tez",
"mime_type": "audio/ogg; codecs=opus",
"voice": true
},
"created_at": "2026-08-25T09:07:32Z"
}Exemple de code
{
"id": "wam_01kyc5r9pewu7s3j6m0xyd5qgb",
"direction": "inbound",
"from": { "phone_number": "+14155550100", "bsuid": "US.13491208655302741918" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"video": {
"id": "waf_01kyc4n5yr8xit1e9o4qsw7ufa",
"url": "https://platform.bird.com/v1/whatsapp/messages/wam_01kyc5r9pewu7s3j6m0xyd5qgb/media/waf_01kyc4n5yr8xit1e9o4qsw7ufa",
"mime_type": "video/mp4",
"caption": "The rattle starts around 0:12"
},
"created_at": "2026-08-25T09:11:48Z"
}Exemple de code
{
"id": "wam_01kyd6s0qfxv8t4k7n1yze6rhc",
"direction": "inbound",
"from": { "phone_number": "+14155550100", "bsuid": "US.13491208655302741918" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"document": {
"id": "waf_01kyd5p6zs9yju2f0p5rtx8vgb",
"url": "https://platform.bird.com/v1/whatsapp/messages/wam_01kyd6s0qfxv8t4k7n1yze6rhc/media/waf_01kyd5p6zs9yju2f0p5rtx8vgb",
"mime_type": "application/pdf",
"filename": "invoice-A1B2C3.pdf"
},
"created_at": "2026-08-25T09:15:02Z"
}id and mime_type appear only on an inbound message, since Bird learns them by fetching the file. Fetching inbound media covers reading the bytes.
Reading quick replies
A tap on a reply button or a list row arrives as its own inbound message carrying interactive_reply. type says which, and the nested object of the same name carries the slug and text you set on the send:
Exemple de code
{
"id": "wam_01kyb2m4xq7whs0d8n3prv6tez",
"direction": "inbound",
"from": { "phone_number": "+14155550100" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"interactive_reply": {
"type": "button",
"button": { "slug": "cancel-booking", "text": "Cancel" }
},
"created_at": "2026-08-25T09:04:11Z"
}A list row replaces button with list, which adds the row's description:
Exemple de code
{
"interactive_reply": {
"type": "list",
"list": {
"slug": "priority_express",
"text": "Priority Mail Express",
"description": "Next day to 2 days"
}
}
}The other two interactive types answer without an interactive_reply at all. A location request comes back as an ordinary inbound location:
Exemple de code
{
"location": {
"latitude": 37.7793,
"longitude": -122.4193,
"name": "Embarcadero Plaza",
"address": "1 Market St, San Francisco, CA 94105"
}
}A contact info request comes back as contact_cards, below. An integration watching only interactive_reply misses both.
Contact cards
contact_cards is the one arm that exists on the read side only: a contact can share a card, but you cannot send one. origin says which way it arrived, contact_request for a button you sent asking for their number and other for a card they shared unprompted. More values can appear later, so treat one you don't recognize as another way of sharing rather than an error.
Exemple de code
{
"id": "wam_01kyb2m4xq7whs0d8n3prv6tez",
"direction": "inbound",
"from": { "phone_number": "+14155550100" },
"to": { "phone_number": "+13124495569" },
"status": "received",
"contact_cards": [
{
"origin": "contact_request",
"phone_numbers": [{ "phone_number": "+14155550100", "type": "CELL" }]
}
],
"created_at": "2026-08-25T09:04:11Z"
}Nothing on a card is required. WhatsApp sends the parts the card holds and omits the rest, so a card carrying only an origin still arrives rather than being dropped. A button tap carries the number in phone_numbers and no vcard; a card shared in the chat carries the vcard and usually more besides: name, org, birthday, emails, urls, addresses.
birthday comes off the contact's own device unvalidated, so it's passed through as text in YYYY-MM-DD shape rather than typed as a date. Don't assume it parses.
Fetching inbound media
An inbound image, video, audio, sticker or document arrives as a reference, not the file itself:
Exemple de code
{
"image": {
"id": "waf_01kyb2m4xq7whs0d8n3prv6tez",
"url": "https://platform.bird.com/v1/whatsapp/messages/wam_01kya19eknftrs2s6p82asmvnh/media/waf_01kyb2m4xq7whs0d8n3prv6tez",
"mime_type": "image/jpeg",
"caption": "Is this the right part?"
}
}Fetch the bytes from that url, following the redirect. No SDK carries a typed method for this operation, so the SDK tabs use each client's raw-request escape hatch:
const redirect = await bird.request<{ url: string }>({
method: "GET",
path: "/v1/whatsapp/messages/wam_01kya19eknftrs2s6p82asmvnh/media/waf_01kyb2m4xq7whs0d8n3prv6tez",
});
const bytes = await fetch(redirect.url).then((r) => r.arrayBuffer());redirect = client.get(
"/v1/whatsapp/messages/wam_01kya19eknftrs2s6p82asmvnh/media/waf_01kyb2m4xq7whs0d8n3prv6tez"
)
bytes_ = httpx.get(redirect["url"]).contentvar out struct {
URL string `json:"url"`
}
if err := client.Get(context.Background(), "/v1/whatsapp/messages/wam_01kya19eknftrs2s6p82asmvnh/media/waf_01kyb2m4xq7whs0d8n3prv6tez", &out); err != nil {
log.Fatal(err)
}
resp, err := http.Get(out.URL)$redirect = $bird->get('/v1/whatsapp/messages/wam_01kya19eknftrs2s6p82asmvnh/media/waf_01kyb2m4xq7whs0d8n3prv6tez');
$bytes = file_get_contents($redirect['url']);bird whatsapp media wam_01kya19eknftrs2s6p82asmvnh waf_01kyb2m4xq7whs0d8n3prv6tez --output part.jpgcurl -L "https://us1.platform.bird.com/v1/whatsapp/messages/wam_01kya19eknftrs2s6p82asmvnh/media/waf_01kyb2m4xq7whs0d8n3prv6tez" \
-H "Authorization: Bearer $BIRD_API_KEY" \
-o part.jpgIt answers 302 to a presigned URL valid for about 15 minutes, 410 once the 30-day retention window lapses, and 404 for media Bird doesn't recognize. The presigned URL carries its own credential, so the redirected hop must not also carry your Authorization header; sending both fails the request. curl drops the header on a cross-host redirect on its own, but a client that forwards headers verbatim needs the Location fetched as a separate, unauthenticated call.
Quoted replies
in_reply_to_message_id names the message an inbound one answers, when WhatsApp marks it as a reply:
Exemple de code
{
"id": "wam_01kyb2m4xq7whs0d8n3prv6tez",
"direction": "inbound",
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"text": { "body": "Yes, that one" }
}WhatsApp does not mark every reply, and an unmarked one carries no ID at all. If correlation has to be reliable, put your own reference in metadata on the send instead. See Quoting a message for the outbound side.
The webhook
Subscribe to whatsapp.received to act on an inbound message as it arrives rather than polling the list. The payload carries the content on top of the event envelope, so an endpoint needs no follow-up read:
Exemple de code
{
"type": "whatsapp.received",
"timestamp": "2026-08-25T09:04:11.118Z",
"data": {
"whatsapp_id": "wam_01kyb2m4xq7whs0d8n3prv6tez",
"workspace_id": "ws_01ky7m235keycbnwyajabe1a6b",
"direction": "inbound",
"from": { "phone_number": "+14155550100", "display_name": "Alex Rivera" },
"to": { "phone_number": "+13124495569" },
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"interactive_reply": {
"type": "button",
"button": { "slug": "cancel-booking", "text": "Cancel" }
},
"tags": null,
"metadata": null
}
}See WhatsApp events for the envelope in full and the rest of the event list.
Next steps
- Service messages: the read shape of each of the eight content arms
- How sending works: replying inside the service window, and quoting a message
- WhatsApp events: the full event list, over the API or webhooks
- WhatsApp log: browsing the conversation in the dashboard