Documentation
Sign inGet started

WhatsApp media carousels

A media carousel is a set of two to ten cards the recipient swipes through side by side, each with its own image or video, its own short text, and its own buttons. Use it to show several items at once, such as a handful of products, rather than sending one message per item.
Set interactive.type to carousel, with a message-level body_text and a cards array of 2 to 10 entries:
const msg = await bird.whatsapp.send({
  to: "+16505551234",
  from: "+13124495648",
  interactive: {
    type: "carousel",
    body_text: "Here are two of our latest arrivals, each under $25:",
    cards: [
      {
        header: { type: "image", url: "https://cdn.example.com/plants/blue-echeveria.jpeg" },
        buttons: [
          {
            type: "cta_url",
            cta_url: { text: "Buy now", url: "https://shop.example.com/blue-echeveria" },
          },
        ],
      },
      {
        header: { type: "image", url: "https://cdn.example.com/plants/zebra-haworthia.jpeg" },
        buttons: [
          {
            type: "cta_url",
            cta_url: { text: "Buy now", url: "https://shop.example.com/zebra-haworthia" },
          },
        ],
      },
    ],
  },
});
console.log(msg.id, msg.status);
from is required on every service message: a number your workspace owns, not a Bird-managed one. The full shape adds a card's own text, a second quick-reply button, and a quote of an earlier message:
Code example
{
  "to": "+16505551234",
  "from": "+13124495648",
  "in_reply_to_message_id": "wam_01kya19eknftrs2s6p82asmvnh",
  "interactive": {
    "type": "carousel",
    "body_text": "Here are two of our latest arrivals, each under $25:",
    "cards": [
      {
        "header": { "type": "image", "url": "https://cdn.example.com/plants/blue-echeveria.jpeg" },
        "body_text": "Blue Echeveria. Powdery blue leaves.",
        "buttons": [
          { "type": "quick_reply", "quick_reply": { "slug": "buy-echeveria", "text": "Buy" } },
          { "type": "quick_reply", "quick_reply": { "slug": "info-echeveria", "text": "Details" } }
        ]
      },
      {
        "header": { "type": "image", "url": "https://cdn.example.com/plants/zebra-haworthia.jpeg" },
        "body_text": "Zebra Haworthia. White stripes on deep green leaves.",
        "buttons": [
          { "type": "quick_reply", "quick_reply": { "slug": "buy-haworthia", "text": "Buy" } },
          { "type": "quick_reply", "quick_reply": { "slug": "info-haworthia", "text": "Details" } }
        ]
      }
    ]
  },
  "tags": [{ "name": "category", "value": "catalog" }],
  "metadata": { "order_id": "A-1" }
}
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 carousel takes no message-level header and no footer: the message's body_text is the only text above the cards. See the hub's buttons section for the shared button shape this type's cards reuse.

Cards

Each card carries its own media header, its own short text, and its own buttons:
  • header is required on every card, and it is image or video only: no text and no document header, unlike the other interactive types.
  • body_text is optional. It sits below the card's media, capped shorter than a message body, and allows at most two line breaks.
  • buttons is required: either one cta_url button or up to three quick_reply buttons, never a mix on the same card.
Cards render left to right in the order they appear in the cards array. A card has no footer and no index field of its own; its position in the array is its position in the carousel.

Every card carries the same buttons

Every card in a carousel must carry the same button types, the same number of them, in the same order. A carousel where card 1 has one cta_url button and card 2 has two quick_reply buttons is refused, and so is a carousel where every card has two quick_reply buttons but in a different order.
The reason is how WhatsApp renders the message: a carousel is one card view with a shared layout, not a set of independently laid-out cards. A card with a different button row would break that shared layout, so WhatsApp requires every card to match and Bird checks it before the send is created or charged. A mismatch returns E15059.
Button labels are a separate rule, and it is scoped differently: a label must be unique within a card, not across the whole carousel. "Buy now" on every one of ten cards is fine; "Buy now" twice on the same card returns E15056.

Limits

FieldBound
cards2 to 10 entries
Card headerrequired on every card; image or video only
Card header.urlrequired, no maximum length
Card body_textoptional, 1 to 160 characters, at most 2 line breaks
Card buttons1 to 3 entries: one cta_url, or up to three quick_reply, never mixed
Button label (quick_reply.text, cta_url.text)required, 1 to 20 characters, unique within the card
quick_reply.slugrequired, 1 to 256 characters
cta_url.urlrequired, 1 to 2000 characters
Message body_textrequired, 1 to 1024 characters
Message header, footernot allowed on a carousel: no header, no footer_text
Bird caps quick_reply buttons at three per card. Meta itself states no numeric limit, only that a card takes either one link button or one or more reply buttons, so this ceiling is Bird's own, not WhatsApp's.

Reading the reply

Only a quick_reply card button produces a reply. A tap on it 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": "button",
    "button": {
      "slug": "buy-echeveria",
      "text": "Buy"
    }
  },
  "created_at": "2026-08-25T09:04:11Z"
}
The slug you set on the tapped button comes back verbatim on interactive_reply.button.slug, the same shape a reply-buttons tap produces. 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.
A cta_url button on a card opens its link in the recipient's browser and sends nothing back, the same as a standalone link button.

Free-form carousels and template carousels

This page covers the free-form carousel you send inline with interactive.type: "carousel", deliverable only inside an open customer service window and never reviewed by Meta. WhatsApp templates has its own, separate carousel: a template component authored once, submitted to Meta for approval, and sent by slug like any other template, including outside the window. The two share the word "carousel" and Meta's 2-to-10 card range, and nothing else: different wire shapes, different review paths, and a template carousel's card count is fixed at the template's approval rather than chosen per send. If you're browsing templates and see "carousel" there, that's the template type, not this page.

Limits and edge cases

  • The customer service window has to be open. A carousel 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.
  • Card media has to be publicly reachable when the send dispatches. Bird does not store or proxy the file: WhatsApp fetches each card's url itself, at send time, so a signed URL has to outlive the send.
  • A card media URL that WhatsApp can't fetch is accepted, then fails asynchronously, and is still charged. Bird's request validation only checks that a card's url is a well-formed URI, not that WhatsApp can reach it or that it uses https. An oversize file, a 404, an unresolvable host, or the wrong file type all come back as a 202 at accept, then whatsapp.accepted then whatsapp.sent then whatsapp.failed, with media_rejected on the message's last_error and the cost of the send already charged with no refund path. Test each card's URL before sending, since a broken one is not caught until after the fact.
  • Every card must carry the same buttons. See Every card carries the same buttons above; this is the one carousel rule the request schema can't express on its own, so it's checked separately and returns E15059 rather than a generic validation error.
  • No message-level header or footer. A carousel's only text above the cards is body_text; there's nowhere to put small print the way the other types use footer_text.
  • The reply carries no card index. A card's quick_reply tap reports only {slug, text}, the same shape as a reply-buttons tap, with no field naming which card it came from. If you need to know which card was tapped, encode the card in each button's slug, such as buy-echeveria rather than a bare buy.
  • A cta_url card button generates no inbound event. If you need to know a card was engaged with, use quick_reply buttons on that card instead, or track the click on your own destination URL.
Beyond E15059, the only interactive error specific to a carousel is E15056 for a repeated button label on one card. 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