WhatsApp interactive messages
An interactive message is body text plus something for the recipient to tap: a WhatsApp button, a menu, a link, a card, or a request for their location or contact details. Where a template answer means parsing free text, a WhatsApp menu or a set of WhatsApp buttons gives the recipient a fixed set of choices and gives you back a value you defined. This page covers what the six types share; each type's own page covers its wire shape and its own limits.
The six types
| Type | Bird interactive.type | Header | Footer | Body max |
|---|---|---|---|---|
| Reply buttons | button | text, image, video, document | yes | 1024 |
| List menus | list | text only | yes | 4096 |
| Link buttons | cta_url | text, image, video, document | yes | 1024 |
| Media carousels | carousel | none on the message; image or video per card | no | 1024 message, 160 per card |
| Location requests | location_request_message | none | no | 1024 |
| Contact info requests | request_contact_info | none | no | 1024 |
Every type is free-form: deliverable only inside an open customer service window, and never reviewed by Meta the way a template is.
Interactive messages are free-form content, so the customer service window rule applies: see the customer service window for what that means and what a closed window returns.
Every interactive send also requires from, a number your workspace owns. Bird's managed numbers cannot carry it, so an interactive send needs a number of your own connected first.
The interactive content arm
interactive is one of the mutually exclusive content fields on POST /v1/whatsapp/messages, beside template, text, image, and the rest: exactly one may be present on a send. Inside interactive, type names which of the six variants this is, and that variant's own field carries the rest (buttons, list, cta_url, or cards). The schema bars every other variant's field, so mixing two variants on one send fails validation before it reaches a handler.
For the request envelope, the 202 response model, and safe retries, see How sending works rather than this page re-teaching them.
Here is a minimal interactive message: two WhatsApp buttons on a reply-buttons send, one language at a time.
const msg = await bird.whatsapp.send({
to: "+15551234567",
from: "+13124495648",
interactive: {
type: "button",
body_text: "Your gardening workshop is scheduled for 9am tomorrow.",
buttons: [
{ type: "quick_reply", quick_reply: { slug: "change-booking", text: "Change" } },
{ type: "quick_reply", quick_reply: { slug: "cancel-booking", text: "Cancel" } },
],
},
});
console.log(msg.id, msg.status);msg = client.whatsapp.send(
to="+15551234567",
from_="+13124495648",
interactive={
"type": "button",
"body_text": "Your gardening workshop is scheduled for 9am tomorrow.",
"buttons": [
{"type": "quick_reply", "quick_reply": {"slug": "change-booking", "text": "Change"}},
{"type": "quick_reply", "quick_reply": {"slug": "cancel-booking", "text": "Cancel"}},
],
},
)
print(msg.id, msg.status)package main
import (
"context"
"fmt"
"log"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(option.WithAPIKey(os.Getenv("BIRD_API_KEY")))
if err != nil {
log.Fatal(err)
}
msg, err := client.Whatsapp.Send(context.Background(), bird.WhatsappSendParams{
To: "+15551234567",
From: "+13124495648",
Interactive: &bird.WhatsAppInteractiveSend{
Type: "button",
BodyText: "Your gardening workshop is scheduled for 9am tomorrow.",
Buttons: &[]bird.WhatsAppInteractiveButtonSend{
{Type: "quick_reply", QuickReply: &bird.WhatsAppInteractiveQuickReplyButtonSend{Slug: "change-booking", Text: "Change"}},
{Type: "quick_reply", QuickReply: &bird.WhatsAppInteractiveQuickReplyButtonSend{Slug: "cancel-booking", Text: "Cancel"}},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$interactive = (new WhatsAppMessageSendRequestInteractive())
->setType('button')
->setBodyText('Your gardening workshop is scheduled for 9am tomorrow.')
->setButtons([
(new WhatsAppInteractiveButtonSend())
->setType('quick_reply')
->setQuickReply((new WhatsAppInteractiveButtonSendQuickReply())->setSlug('change-booking')->setText('Change')),
(new WhatsAppInteractiveButtonSend())
->setType('quick_reply')
->setQuickReply((new WhatsAppInteractiveButtonSendQuickReply())->setSlug('cancel-booking')->setText('Cancel')),
]);
$message = $bird->whatsapp->send(
to: '+15551234567',
from: '+13124495648',
interactive: $interactive,
);
echo $message->getId(), ' ', $message->getStatus();bird whatsapp send \
--to +15551234567 \
--from +13124495648 \
--interactive '{"type":"button","body_text":"Your gardening workshop is scheduled for 9am tomorrow.","buttons":[{"type":"quick_reply","quick_reply":{"slug":"change-booking","text":"Change"}},{"type":"quick_reply","quick_reply":{"slug":"cancel-booking","text":"Cancel"}}]}'curl -X POST "https://{region}.platform.bird.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"from": "+13124495648",
"interactive": {
"type": "button",
"body_text": "Your gardening workshop is scheduled for 9am tomorrow.",
"buttons": [
{ "type": "quick_reply", "quick_reply": { "slug": "change-booking", "text": "Change" } },
{ "type": "quick_reply", "quick_reply": { "slug": "cancel-booking", "text": "Cancel" } }
]
}
}'Buttons
Four of the six types place a button, and all of them draw on the same shape: a discriminated object whose type is quick_reply or cta_url, each carrying its own nested field of the same name. A quick_reply button carries slug and text; a cta_url button carries text and url. Which types accept which button shape:
- Reply buttons send only quick_reply buttons, 1 to 3 of them.
- Link buttons send exactly one cta_url button.
- Media carousels put buttons on each card: either one cta_url button, or up to three quick_reply buttons, and every card in the carousel must agree.
- List menus use rows inside sections rather than this button object, covered on their own page.
A quick_reply button's slug is your own handle for that button. It is never shown to the recipient, only its text label is, and the slug is echoed verbatim on the reply. That round trip is what makes a reply correlatable to the button that produced it, so this is worth saying once, here, rather than on each leaf page.
Reading a reply
Pressing a button or choosing a menu row sends its own inbound message, carrying an interactive_reply object. interactive_reply.type is button or list; whichever it is, the nested object carries the slug and text you declared, the tapped label the recipient actually saw. The two request types, location requests and contact info requests, answer differently: a location request's reply is an ordinary inbound location message, and a contact info request's reply is an inbound contact card, not an interactive_reply at all.
A reply reaches you through the message list and GET /v1/whatsapp/messages/{id}, the same way any inbound WhatsApp message does. To act on one as it arrives rather than polling, subscribe to the whatsapp.received webhook: its payload carries interactive_reply, so it already names the button or row that was tapped.
Quoting a message to correlate a reply
in_reply_to_message_id on a send quotes an earlier message from the same conversation, and every message, sent or received, echoes it back on a read. It is one field for both directions.
The correlation this buys you is asymmetric. A tap on a WhatsApp button or menu row carries Meta's own context, so in_reply_to_message_id resolves to the message that offered it. A shared contact card carries no context at all, so it resolves to nothing: you correlate a contact info request's reply on from and timing, not on this field.
Resolution goes through a message-context store, and a miss omits the field rather than reporting one. That is indistinguishable, on the wire, from a reply that answers nothing at all. An integration that needs reliable correlation should not rely on this field alone: carry your own metadata on the send and match on that instead.
The window a message stays quotable in is limited; see E15057 for what happens once it closes. Sending WhatsApp messages owns the send-side field: its length, its resolution, and the request shape.
Errors
Five error codes are specific to interactive content. Three of the five only fire on the types that have the field they check, so the fourth column names which types can actually reach each one.
| Code | Status | What triggers it | Applies to |
|---|---|---|---|
| E15055 WhatsAppInteractiveLimitExceeded | 422 | The message exceeds a limit for its type; today, more than 10 rows across a list's sections. | List menus only |
| E15056 WhatsAppInteractiveDuplicateLabel | 422 | Two buttons or rows in the same message share a label. | Any type with labelled buttons or rows: reply buttons, list menus, media carousels |
| E15059 WhatsAppInteractiveCarouselButtonsMismatch | 422 | A carousel's cards do not all carry the same buttons. | Media carousels only |
| E15057 WhatsAppInReplyToNotFound | 422 | The quoted message is not one this workspace holds. | Any type, when it carries in_reply_to_message_id |
| E15058 WhatsAppInReplyToNotQuotable | 422 | The quoted message cannot be quoted. | Any type, when it carries in_reply_to_message_id |
Every interactive send can also hit the errors any WhatsApp send can: a closed customer service window, a missing or invalid sender, an invalid recipient, or ambiguous content. Those are shared across every WhatsApp content type, not specific to interactive messages; see How sending works for that list rather than a copy of it here.
Next steps
- How sending works: the request envelope, the 202 model, and safe retries
- WhatsApp events: follow delivery per message, over the API or webhooks
- WhatsApp templates: the messages you can still send once the window is closed