Receiving WhatsApp messages
Inbound messages land on the same resource as outbound ones, with 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 extends the customer service window to 24 hours after that message's own timestamp, which is what makes a free-form reply of your own deliverable. A message that reaches Bird late therefore carries the window its contact actually granted, and a later deadline already on record is never shortened.
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:
Przykład kodu
{
"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"
}from names the contact by whatever identity WhatsApp reports: an E.164 phone_number, a bsuid, or both, plus the username and display_name they publish. A contact who has adopted a WhatsApp username can reach you with no phone number at all; see business-scoped user IDs for what to store and how to ask for a number.
One of these content fields, an arm, carries what they sent, and exactly one arm is set on any message. Each has its own page, with the read shape, the whatsapp.received payload, and what to watch for:
| 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, plus voice on a voice note; no caption exists |
| 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 |
| contact_cards | One or more contact cards the contact shared |
| interactive_reply | The slug and text of the button or row the contact tapped |
| unsupported | The WhatsApp content type the API does not model, such as a reaction |
Two taps arrive on an arm you might not expect. A location request answers as an ordinary inbound location, and a contact info request answers as contact_cards, so an integration watching only interactive_reply for a tap misses both.
Fetching inbound media
An inbound image, video, audio, sticker, or document arrives as a reference to a file Bird stored, rather than as the file itself:
Przykład kodu
{
"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 with the channel's media method, passing the message id and the media id:
const media = await bird.whatsapp.messages.media(
"wam_01kya19eknftrs2s6p82asmvnh",
"waf_01kyb2m4xq7whs0d8n3prv6tez",
);
console.log(media.contentType, media.contentLength);media = client.whatsapp.messages.media(
"wam_01kya19eknftrs2s6p82asmvnh", "waf_01kyb2m4xq7whs0d8n3prv6tez"
)
print(media.content_type, media.content_length)media, err := client.Whatsapp.Messages.Media(context.Background(),
"wam_01kya19eknftrs2s6p82asmvnh", "waf_01kyb2m4xq7whs0d8n3prv6tez")
if err != nil {
log.Fatal(err)
}
fmt.Println(media.ContentType, media.ContentLength)$media = $bird->whatsapp->messages->media('wam_01kya19eknftrs2s6p82asmvnh', 'waf_01kyb2m4xq7whs0d8n3prv6tez');
file_put_contents('photo.jpg', $media->data);
echo $media->contentType, ' ', $media->contentLength;bird whatsapp media <message-id> <media-id>{
"name": "whatsapp_media",
"arguments": {
"media_id": "<media-id>",
"message_id": "<message-id>"
}
}curl -L -X GET "https://{region}.platform.bird.com/v1/whatsapp/messages/{message_id}/media/{media_id}" \
-H "Authorization: Bearer $TOKEN"You get the bytes back with the mime_type storage declared for them. An id Bird doesn't recognize returns 404, and an outbound message has no stored media to serve.
A message reads back for 30 days after it arrives, and its media never outlives it. This endpoint reads the message before it serves the file, so once that window passes both answer 404. Store any file you need for longer while the message is still readable. The one case that ends earlier is the stored bytes going before the window is up: the fetch answers 410 E15021, and the message still reads back with the media's mime_type and caption.
Under the covers this endpoint answers 302 with a presigned URL valid for 15 minutes. The SDKs and the CLI take that hop for you. Calling it directly, the presigned URL carries its own credential, so the redirected request must not also send your Authorization header. Sending both fails. curl -L drops the header on a cross-host redirect on its own; a client that forwards headers verbatim needs the Location fetched as a separate, unauthenticated request.
Quoted replies
in_reply_to_message_id names the message an inbound one answers, when WhatsApp marks it as a reply:
Przykład kodu
{
"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. The field is also omitted when the quoted message cannot be matched to one Bird holds: a message sent before this workspace started recording them, or one past the 15 days Bird keeps WhatsApp's own message ids for. A miss omits the field rather than reporting one, which reads the same as a reply that answers nothing.
Treat the field as a hint rather than a key. metadata on your own send does not help here, because it stays on your message and never travels to the contact's reply, so an integration that has to know which question an answer belongs to tracks the question it last put to that contact itself. 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:
Przykład kodu
{
"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 send side of the same content arms
- Sending WhatsApp messages: 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