WhatsApp list menus
A list menu opens as a tappable button under a WhatsApp message and expands into rows grouped into sections, so the recipient picks one from a longer set of choices instead of typing free text. Use it once the choice list is longer than three; for three or fewer, reply buttons are the simpler shape.
Send a list menu
Set interactive.type to list, with a body_text and a list object carrying button_text and at least one section:
const msg = await bird.whatsapp.send({
to: "+16505551234",
from: "+13124495648",
interactive: {
type: "list",
body_text: "Which shipping option do you prefer?",
list: {
button_text: "Shipping options",
sections: [
{
title: "As soon as possible",
rows: [{ slug: "priority_express", text: "Priority Mail Express" }],
},
],
},
},
});
console.log(msg.id, msg.status);msg = client.whatsapp.send(
to="+16505551234",
from_="+13124495648",
interactive={
"type": "list",
"body_text": "Which shipping option do you prefer?",
"list": {
"button_text": "Shipping options",
"sections": [
{
"title": "As soon as possible",
"rows": [{"slug": "priority_express", "text": "Priority Mail Express"}],
}
],
},
},
)
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: "+16505551234",
From: "+13124495648",
Interactive: &bird.WhatsAppInteractiveSend{
Type: "list",
BodyText: "Which shipping option do you prefer?",
List: &bird.WhatsAppInteractiveListSend{
ButtonText: "Shipping options",
Sections: []bird.WhatsAppInteractiveListSectionSend{
{
Title: "As soon as possible",
Rows: []bird.WhatsAppInteractiveListRowSend{
{Slug: "priority_express", Text: "Priority Mail Express"},
},
},
},
},
},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$interactive = (new WhatsAppMessageSendRequestInteractive())
->setType('list')
->setBodyText('Which shipping option do you prefer?')
->setList(
(new WhatsAppInteractiveSendList())
->setButtonText('Shipping options')
->setSections([
(new WhatsAppInteractiveListSectionSend())
->setTitle('As soon as possible')
->setRows([
(new WhatsAppInteractiveListRowSend())
->setSlug('priority_express')
->setText('Priority Mail Express'),
]),
]),
);
$message = $bird->whatsapp->send(
to: '+16505551234',
from: '+13124495648',
interactive: $interactive,
);
echo $message->getId(), ' ', $message->getStatus();bird whatsapp send \
--to +16505551234 \
--from +13124495648 \
--interactive '{"type":"list","body_text":"Which shipping option do you prefer?","list":{"button_text":"Shipping options","sections":[{"title":"As soon as possible","rows":[{"slug":"priority_express","text":"Priority Mail Express"}]}]}}'curl -X POST "https://{region}.platform.bird.com/v1/whatsapp/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+16505551234",
"from": "+13124495648",
"interactive": {
"type": "list",
"body_text": "Which shipping option do you prefer?",
"list": {
"button_text": "Shipping options",
"sections": [
{
"title": "As soon as possible",
"rows": [{ "slug": "priority_express", "text": "Priority Mail Express" }]
}
]
}
}
}'from is required on every service message: a number your workspace owns, not a Bird-managed one. The full shape adds an optional text header, a footer, a quote of an earlier message, a second section, and a row description:
Code example
{
"to": "+16505551234",
"from": "+13124495648",
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Choose a shipping option" },
"body_text": "Which shipping option do you prefer?",
"footer_text": "Lucky Shrub",
"list": {
"button_text": "Shipping options",
"sections": [
{
"title": "As soon as possible",
"rows": [
{
"slug": "priority_express",
"text": "Priority Mail Express",
"description": "Next day to 2 days"
}
]
},
{
"title": "I can wait a bit",
"rows": [
{
"slug": "ground_advantage",
"text": "Ground Advantage",
"description": "2 to 5 days"
}
]
}
]
}
},
"tags": [{ "name": "flow", "value": "shipping-choice" }],
"metadata": { "order_id": "A1B2C3" }
}in_reply_to_message_id quotes an earlier message in the same conversation. See the hub's quoting a message to correlate a reply for how resolution works and what it can miss.
A list menu uses list.sections rather than the shared button object; see the hub's buttons section for how the other types use that shape instead.
Sections and rows
list.button_text is the label on the button that opens the menu, up to 20 characters. Tapping it opens the menu: each section renders under its own title, and each row under a section shows its text label with its optional description underneath, in smaller type. A section's title is required even when the menu has only one section, because WhatsApp shows it above that section's rows regardless.
A row's slug is your own handle for that row, never shown to the recipient. Only text renders in the chat, and the slug is echoed verbatim on the reply, the same round trip the hub describes for buttons. Nothing validates the characters in slug: put whatever your own routing needs there.
Limits
| Field | Bound |
|---|---|
| Sections per message | 1 to 10 |
| Rows per section | 1 to 10 |
| Rows across all sections | 10, total |
| Section title | required, 1 to 24 characters |
| Row slug | required, 1 to 200 characters, any characters |
| Row text (label) | required, 1 to 24 characters, unique across the whole message |
| Row description | optional, up to 72 characters |
| list.button_text | required, 1 to 20 characters |
| body_text | required, 1 to 4096 characters |
| footer_text | optional, up to 60 characters |
| header.text | 1 to 60 characters |
Two of these bounds are easy to misread.
The row cap is ten across all sections, not ten per section. Each section independently allows up to 10 rows, but the message as a whole still tops out at 10 rows total. Three sections of 4 rows each pass every per-section check and still exceed the total, which is enforced separately from the per-section count and returns E15055 with a message naming how many rows the send actually offered.
body_text is the only one that keeps the full 4096 characters. Every other interactive type narrows its body to 1024; a list menu is the only one WhatsApp allows to use the full length, so reach for it when the choice needs more context than a short prompt.
Reading the choice
A row tap arrives as its own inbound message, carrying interactive_reply:
Code example
{
"id": "wam_01kyb2m4xq7whs0d8n3prv6tez",
"direction": "inbound",
"from": { "phone_number": "+16505551234" },
"to": { "phone_number": "+13124495648" },
"status": "received",
"in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
"interactive_reply": {
"type": "list",
"list": {
"slug": "priority_express",
"text": "Priority Mail Express",
"description": "Next day to 2 days"
}
},
"created_at": "2026-08-25T09:04:11Z"
}The slug you set on the chosen row comes back verbatim, so you can branch on it directly. description is present only when the row carried one. You see this reply through the message list or GET /v1/whatsapp/messages/{id}; see the hub's reading a reply for that path in full.
Limits and edge cases
- The customer service window has to be open. A list menu is a service message, deliverable only inside an open window; see the hub's customer service window. The window check fails open, so a 202 is not proof the window was actually open when the send goes out.
- from must be a number your workspace owns. Omitting it, or naming a number that isn't a connected sender, is rejected before the send is created.
- The header is text only, and a media header fails as a generic error. A list is the only interactive type whose header cannot carry an image, video, or document. Sending one anyway does not come back as a WhatsApp-specific error code: it fails general request validation, so a developer debugging by error code sees only that the request failed validation and has to read the details to find the header.
- The cross-section row cap is the trap. Per-section limits and the message-wide total both cap at 10, so they read as the same number twice. They are not additive: a menu that passes every per-section check can still exceed the total, and only the total is what gets enforced.
- Row labels must be unique across the whole message, not just within a section. Two rows sharing a text label, even in different sections, are refused with E15056. This is Bird's own rule, not one Meta states: the reply names the label tapped, so a duplicate would leave the answer ambiguous, and distinct slug values underneath don't help resolve it.
- A Meta-side rejection beyond Bird's own checks is vague and can arrive late. Bird's row-count and duplicate-label checks catch the two most common mistakes before anything is created. Anything Meta rejects beyond that surfaces after the 202, on the message's last_error, rather than on the send call itself.
E15057 and E15058 can fire on any type that carries in_reply_to_message_id, when the quoted message doesn't resolve or can't be quoted. For the errors any WhatsApp send can hit, a closed window, a missing or invalid sender, or an invalid recipient, see the hub's errors and How sending works.
Next steps
- WhatsApp interactive messages: what all six interactive types share
- Reply buttons: for three or fewer choices
- How sending works: the request envelope, the 202 model, and safe retries