Skip to Content
Interactive Messages

Interactive Messages

Interactive messages are the rich, tappable UI of WhatsApp: reply buttons, link/copy/call call-to-action buttons, single-select lists, product carousels, reminder and location request buttons, and more. Zaileys renders them natively — including on personal (non-business) accounts — because it relays each interactive message through WhatsApp’s native_flow node for you. There is no separate “business API” requirement.

Every interactive message is sent through the same fluent builder you use for Sending Messages: client.send(jid).<method>(...). Taps and selections come back as the button-click and list-select events.

All builder methods are chainable and awaited. client.send(jid) returns a builder; calling a content method (.buttons(), .list(), .carousel(), .template()) sets the content, and awaiting the builder sends it and resolves to the sent message’s WAMessageKey.

At a glance

MethodSignatureSends
.buttons()buttons(buttons, opts?)Reply + CTA buttons (up to 10) with optional header/footer/overflow
.template()template(opts)Header/body/footer shortcut + up to 3 reply buttons
.list()list(opts)A single_select list grouped into sections (up to 10 rows total)
.carousel()carousel(cards, opts?)Horizontal product cards (up to 10), each with its own media + buttons

Buttons

buttons(buttons, opts?) accepts an array of buttons (1–10) of mixed types, plus an options object for the header, footer, and overflow behavior. Reply taps come back on the button-click event; CTA buttons (url/copy/call) are handled by the WhatsApp client itself and do not emit an event.

Reply buttons (simplest)

A reply button is the default. It needs a unique id and a text label. When tapped, it fires button-click with that id.

import { Client } from 'zaileys' const client = new Client() const jid = '628xxxxxxxxxx@s.whatsapp.net' await client.send(jid).buttons( [ { id: 'yes', text: 'Yes' }, { id: 'no', text: 'No' }, ], { text: 'Pick one', footer: 'tap a button' }, ) client.on('button-click', (ctx) => { console.log(ctx.buttonId, ctx.buttonText) // "yes" "Yes" })

The full reply-button type is { type?: 'reply'; id: string; text: string }. The type is optional because reply is the default — { id, text } and { type: 'reply', id, text } are equivalent.

Reply button ids must be unique within a single message and non-empty — a duplicate or empty id throws INVALID_OPTIONS.

CTA buttons (url / copy / call)

CTA (call-to-action) buttons perform a client-side action when tapped. They have no id (you do not receive an event for them) and are distinguished by their type. Field names matter — verify against the table below.

await client.send(jid).buttons( [ { type: 'url', text: 'Open GitHub', url: 'https://github.com/zeative/zaileys', webview: true }, { type: 'copy', text: 'Copy code', code: 'ZAILEYS-2026' }, { type: 'call', text: 'Call us', phone: '6287833764462' }, ], { text: 'CTA buttons: link / copy / call', footer: 'tap any' }, )
TypeShapeAction
url{ type: 'url'; text: string; url: string; webview?: boolean }Opens url. webview: true opens it inside an in-app webview instead of the external browser (default false).
copy{ type: 'copy'; text: string; code: string }Copies code to the device clipboard.
call{ type: 'call'; text: string; phone: string }Dials phone (use full international format, digits only, e.g. 6287833764462).
⚠️

CTA fields are required: a url button without url, a copy without code, or a call without phone throws INVALID_OPTIONS. The text label is required on every button type.

Mixing reply and CTA buttons

You can freely combine reply and CTA buttons in a single message — only the reply taps emit button-click.

await client.send(jid).buttons( [ { id: 'yes', text: 'Yes' }, { type: 'url', text: 'Docs', url: 'https://github.com/zeative/zaileys' }, { type: 'copy', text: 'Copy ID', code: 'ABC123' }, ], { title: 'Mixed buttons', text: 'reply + url + copy in one message' }, )

Reminder buttons

reminder sets a WhatsApp reminder for the message; cancel-reminder cancels one. Both take a text label and an optional id — when id is omitted, the button text is used as the id.

await client.send(jid).buttons( [ { type: 'reminder', text: 'Remind me', id: 'remind_1' }, { type: 'cancel-reminder', text: 'Cancel' }, ], { title: '⏰ Reminder', text: 'Set or cancel a WhatsApp reminder' }, )
TypeShape
reminder{ type: 'reminder'; text: string; id?: string }
cancel-reminder{ type: 'cancel-reminder'; text: string; id?: string }

Location & address request buttons

location asks the user to share their current location; address opens the address form (useful for checkout / delivery flows). For location the text label is optional (a default is used when omitted); for address the text is required and id is optional.

await client.send(jid).buttons( [ { type: 'location', text: 'Share location' }, { type: 'address', text: 'Add address', id: 'addr_1' }, ], { title: '📍 Checkout', text: 'Share your location or delivery address' }, )
TypeShape
location{ type: 'location'; text?: string }
address{ type: 'address'; text: string; id?: string }

The second argument to buttons() (ButtonsContentOptions) controls the body text, footer, and header. A text header appears when you supply title and/or subtitle; a media header appears when you supply image or video.

await client.send(jid).buttons( [ { id: 'go', text: 'Go' }, { id: 'skip', text: 'Skip' }, ], { title: '💡 Tip & Suggest', subtitle: 'header subtitle line', text: 'body text under the header', footer: 'footer', }, )
OptionTypeDefaultDescription
textstring' ' (single space)Body text shown above the buttons.
footerstringnoneSmall footer text below the body.
titlestringnoneHeader title line (renders a text header).
subtitlestringnoneHeader subtitle line (renders a text header).
imagestring | Buffer | URLnoneImage media header (file path, URL, or Buffer).
videostring | Buffer | URLnoneVideo media header.
bottomSheetBottomSheetOptionsnoneCollapse overflow buttons into a bottom sheet — see below.
limitedTimeOfferLimitedTimeOfferOptionsnoneRender a countdown / limited-time-offer banner — see below.

Media header

Pass image or video to attach a media header. The source can be a local path, a URL, or a Buffer (the same MediaSource accepted everywhere — see Media). Zaileys uploads the media to WhatsApp and attaches it to the header automatically.

import { readFileSync } from 'node:fs' await client.send(jid).buttons( [ { id: 'ok', text: 'OK' }, { id: 'no', text: 'No' }, ], { image: readFileSync('./header.png'), // or './header.png' or a URL title: '🖼️ Image header', text: 'interactive message with an image header', footer: 'zaileys', }, )
⚠️

Provide either image or video, not both — if both are present, image wins. Media headers require the socket to support relaying with upload; this is wired up automatically when you send through client.send().

Overflow: bottomSheet

When you have many buttons, bottomSheet collapses the overflow into a tap-to-open sheet so the chat stays tidy. Only the first buttonsLimit buttons show inline; the rest move into the sheet.

await client.send(jid).buttons( [ { id: 's1', text: 'Option 1' }, { id: 's2', text: 'Option 2' }, { id: 's3', text: 'Option 3' }, { id: 's4', text: 'Option 4' }, { id: 's5', text: 'Option 5' }, ], { text: 'Many options — grouped into a bottom sheet', bottomSheet: { listTitle: 'All options', buttonTitle: 'View 5 options', buttonsLimit: 2 }, }, )
OptionTypeDefaultDescription
listTitlestringnoneTitle shown at the top of the opened sheet.
buttonTitlestringnoneLabel of the inline button that opens the sheet.
buttonsLimitnumbernoneNumber of buttons to keep inline before collapsing the rest into the sheet.
dividersnumber[]noneIndices at which to draw dividers between grouped buttons.

limitedTimeOffer (countdown banner)

Renders a limited-time-offer banner with a countdown above the buttons — ideal for flash sales paired with a url or copy CTA.

await client.send(jid).buttons( [ { type: 'url', text: 'Grab the deal', url: 'https://github.com/zeative/zaileys' }, { type: 'copy', text: 'Copy code', code: 'FLASH50' }, ], { title: '⚡ Flash Sale', text: '50% off — ending soon!', limitedTimeOffer: { text: 'Offer ends in', copyCode: 'FLASH50', expiresAt: Math.floor(Date.now() / 1000) + 3600, // unix seconds }, }, )
OptionTypeDefaultDescription
textstringnoneBanner label (e.g. “Offer ends in”).
urlstringnoneURL associated with the offer.
copyCodestringnonePromo code surfaced in the banner.
expiresAtnumbernoneExpiry as a Unix timestamp in seconds (the countdown ends here).
⚠️

expiresAt is in seconds, not milliseconds — use Math.floor(Date.now() / 1000) + secondsFromNow. Passing Date.now() directly (milliseconds) will produce a wildly future expiry.

A message accepts at most 10 buttons (buttons() throws INVALID_OPTIONS beyond that) and requires at least one. An empty button array also throws.


Template

template(opts) is a streamlined header / body / footer shortcut for reply buttons. It is built on top of buttons() — the header is bolded and prepended to the body, and buttons are limited to 3. Use it when you just want a titled message with a couple of quick replies.

await client.send(jid).template({ header: 'Zaileys', body: 'Template message body', footer: 'template footer', buttons: [ { id: 't1', text: 'Action 1' }, { id: 't2', text: 'Action 2' }, ], })
OptionTypeDefaultDescription
headerstringnoneBolded line prepended to the body.
bodystringrequiredMain message text. Must be non-empty.
footerstringnoneFooter text.
buttonsButtonDef[]required1–3 reply buttons, each { id, text }.

template() buttons are plain reply buttons ({ id, text }) and emit button-click on tap. For CTA/url/copy/call buttons, use buttons() instead. A missing/empty body, an empty buttons array, or more than 3 buttons throws INVALID_OPTIONS.


List (single_select)

list(opts) builds a single_select list: a button that opens a sheet of rows grouped into sections. The user picks exactly one row, which comes back on the list-select event with its id.

await client.send(jid).list({ title: '🍔 Menu', description: 'Pick your order', buttonText: 'View menu', footerText: 'zaileys', sections: [ { title: 'Food', rows: [ { id: 'pizza', title: 'Pizza', description: '$6' }, { id: 'ramen', title: 'Ramen', description: '$5' }, ], }, { title: 'Drinks', rows: [ { id: 'coffee', title: 'Coffee', description: '$2' }, { id: 'tea', title: 'Tea', description: '$1' }, ], }, ], }) client.on('list-select', (ctx) => { console.log('selected:', ctx.rowId, ctx.title) // "pizza" "View menu" })

ListOptions

OptionTypeDefaultDescription
buttonTextstringrequiredLabel of the button that opens the list. Must be non-empty.
titlestringnoneHeader title shown above the body.
descriptionstring' 'Body text shown above the open-list button.
footerTextstringnoneFooter text.
sectionsListSection[]requiredOne or more sections — see below.

ListSection and rows

type ListSection = { title: string rows: Array<{ id: string; title: string; description?: string }> }
FieldTypeDefaultDescription
section.titlestringSection heading inside the list.
row.idstringrequiredUnique id returned as ctx.rowId on selection. Must be non-empty and unique across all sections.
row.titlestringrequiredRow label. Must be non-empty.
row.descriptionstringnoneSecondary line under the row title.
⚠️

A list accepts at most 10 rows total across all sections, requires at least one section, and each section requires at least one row. Row ids must be unique across the whole list — a duplicate id throws INVALID_OPTIONS.


carousel(cards, opts?) sends a horizontally swipeable set of product cards (1–10). Each card has its own optional media header, title/subtitle, body, footer, and buttons. Card buttons behave exactly like buttons() — reply buttons emit button-click, CTA buttons act client-side.

import { readFileSync } from 'node:fs' const pizza = readFileSync('./pizza.png') const ramen = readFileSync('./ramen.png') await client.send(jid).carousel( [ { title: 'Pizza Mozzarella', body: '$6', footer: 'zaileys', image: pizza, buttons: [ { id: 'buy_pizza', text: 'Order' }, { type: 'url', text: 'Detail', url: 'https://github.com/zeative/zaileys' }, ], }, { title: 'Ramen Kaldu', body: '$5', footer: 'zaileys', image: ramen, buttons: [ { id: 'buy_ramen', text: 'Order' }, { type: 'copy', text: 'Promo', code: 'RAMEN5' }, ], }, ], { text: '🛍️ Product Carousel' }, )

CarouselCard

FieldTypeDefaultDescription
titlestringnoneCard header title.
subtitlestringnoneCard header subtitle.
bodystring' 'Card body text.
footerstringnoneCard footer text.
imagestring | Buffer | URLnoneImage media header for this card.
videostring | Buffer | URLnoneVideo media header for this card.
buttonsArray<ButtonDef | InteractiveButton>noneButtons for this card — same shapes as buttons().

The opts argument only carries the carousel-level intro text:

OptionTypeDefaultDescription
textstring' 'Text shown above the card strip.

A carousel accepts at most 10 cards and requires at least one. Each card’s buttons are validated like a normal buttons() call (reply ids unique per card, CTA fields required, max 10 buttons per card).


Handling responses

Interactive taps and selections arrive as events on the client. See Events for the full event list and registration patterns.

Fired when a user taps a reply button (from .buttons(), .template(), or a carousel card). CTA buttons (url/copy/call) do not fire this event.

client.on('button-click', (ctx) => { console.log(ctx.buttonId) // the reply button's `id` console.log(ctx.buttonText) // the button label (may be undefined) console.log(ctx.sender.jid) // who tapped it })

ButtonClickPayload:

FieldTypeDescription
keyWAMessageKeyKey of the message containing the tapped button.
buttonIdstringThe reply button’s id.
buttonTextstring (optional)The button label, when present.
senderSenderInfoThe tapper (jid, pushName, username, lid, pn, isMe).
timestampnumberUnix timestamp of the tap.

Routing taps to handlers

Because every reply button and list row carries the id you defined, a single handler can route on it:

const actions: Record<string, () => Promise<unknown>> = { yes: () => client.send(jid).text('You said yes!'), no: () => client.send(jid).text('Maybe next time.'), } client.on('button-click', async (ctx) => { await actions[ctx.buttonId]?.() }) client.on('list-select', async (ctx) => { await client.send(ctx.sender.jid).text(`You picked: ${ctx.rowId}`) })

Use stable, meaningful ids ('order_pizza', 'remind_1') so your handlers stay readable. Ids are the contract between the message you send and the event you receive.


Putting it together

Connect the client

import { Client } from 'zaileys' const client = new Client() client.on('qr', ({ qrString }) => console.log('Scan QR:', qrString))

Send an interactive message on connect

const TO = '628xxxxxxxxxx@s.whatsapp.net' client.on('connect', async () => { await client.send(TO).buttons( [ { id: 'yes', text: 'Yes' }, { id: 'no', text: 'No' }, { type: 'url', text: 'Docs', url: 'https://github.com/zeative/zaileys' }, ], { title: 'Zaileys', text: 'Ready to go?', footer: 'tap a button' }, ) })

Handle the response

client.on('button-click', (ctx) => { console.log(`button-click → ${ctx.buttonId} from ${ctx.sender.jid}`) })

Gotchas

⚠️
  • Limits throw, they don’t truncate. More than 10 buttons, more than 10 list rows, more than 10 carousel cards, or more than 3 template buttons throws INVALID_OPTIONS at build time — catch it or stay within bounds.
  • Empty labels throw. Every button needs a non-empty text (except location, where text is optional). Reply buttons additionally need a non-empty, unique id.
  • CTA buttons emit no event. Only reply buttons (button-click) and list rows (list-select) come back to your code; url/copy/call/reminder/location/address are handled by the WhatsApp client.
  • limitedTimeOffer.expiresAt is in seconds. Don’t pass Date.now() (milliseconds) directly.

See also

  • Events — the full event catalog, including button-click and list-select.
  • Sending Messages — the fluent client.send(jid) builder, replies, mentions, and disappearing messages.
  • Media — accepted media sources (path, URL, Buffer) for header image/video and carousel cards.
  • Rich Responses — AI-style rich text and markdown rendering.
Last updated on