Complete, self-contained reference for connecting to WhatsApp via Baileys and sending interactive native-flow messages (buttons, PIX payment, review & pay).
All code below imports exclusively from baileys and its peer dependencies. Replace
placeholder values (JIDs, PIX keys, amounts) with real ones for production use.
Important: Interactive buttons and payment messages are rendered on the mobile WhatsApp app and the official Business API. WhatsApp Web/Desktop may show them as plain text or "unsupported message".
- Connection Setup (Official Example)
- Sending Interactive Buttons (Native Flow)
- Sending a PIX Payment Button
- Sending a Review & Pay Button
- Utility: Building the
bizRelay Node
Straight from the Baileys repository — handles multi-file auth state, automatic reconnection, pairing-code flow, and all core events.
import { Boom } from '@hapi/boom'
import NodeCache from '@cacheable/node-cache'
import readline from 'readline'
import makeWASocket, {
CacheStore,
DEFAULT_CONNECTION_CONFIG,
DisconnectReason,
fetchLatestBaileysVersion,
generateMessageIDV2,
getAggregateVotesInPollMessage,
isJidNewsletter,
makeCacheableSignalKeyStore,
proto,
useMultiFileAuthState,
WAMessageContent,
WAMessageKey,
} from 'baileys'
import P from 'pino'
const logger = P({
level: 'trace',
transport: {
targets: [
{
target: 'pino-pretty',
options: { colorize: true },
level: 'trace',
},
{
target: 'pino/file',
options: { destination: './wa-logs.txt' },
level: 'trace',
},
],
},
})
logger.level = 'trace'
const doReplies = process.argv.includes('--do-reply')
const usePairingCode = process.argv.includes('--use-pairing-code')
// External map to store retry counts of messages when decryption/encryption fails.
// Keep this out of the socket itself to prevent a retry loop across socket restarts.
const msgRetryCounterCache = new NodeCache() as CacheStore
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
const question = (text: string) =>
new Promise<string>((resolve) => rl.question(text, resolve))
const startSock = async () => {
const { state, saveCreds } = await useMultiFileAuthState('baileys_auth_info')
if (process.env.ADV_SECRET_KEY) {
state.creds.advSecretKey = process.env.ADV_SECRET_KEY
}
const { version, isLatest } = await fetchLatestBaileysVersion()
logger.debug({ version: version.join('.'), isLatest }, 'using latest WA version')
const sock = makeWASocket({
version,
logger,
waWebSocketUrl:
process.env.SOCKET_URL ?? DEFAULT_CONNECTION_CONFIG.waWebSocketUrl,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
msgRetryCounterCache,
generateHighQualityLinkPreview: true,
getMessage,
})
sock.ev.process(async (events) => {
// ── Connection lifecycle ───────────────────────────────────────────
if (events['connection.update']) {
const update = events['connection.update']
const { connection, lastDisconnect, qr } = update
if (connection === 'close') {
if (
(lastDisconnect?.error as Boom)?.output?.statusCode !==
DisconnectReason.loggedOut
) {
startSock()
} else {
logger.fatal('Connection closed. You are logged out.')
}
}
if (qr && usePairingCode && !sock.authState.creds.registered) {
const phoneNumber = await question('Please enter your phone number:\n')
const code = await sock.requestPairingCode(phoneNumber)
console.log(`Pairing code: ${code}`)
}
logger.debug(update, 'connection update')
}
// ── Credentials persistence ────────────────────────────────────────
if (events['creds.update']) {
await saveCreds()
logger.debug({}, 'creds save triggered')
}
// ── Labels ─────────────────────────────────────────────────────────
if (events['labels.association']) {
logger.debug(events['labels.association'], 'labels.association event fired')
}
if (events['labels.edit']) {
logger.debug(events['labels.edit'], 'labels.edit event fired')
}
// ── Calls ──────────────────────────────────────────────────────────
if (events['call']) {
logger.debug(events['call'], 'call event fired')
}
// ── History sync ───────────────────────────────────────────────────
if (events['messaging-history.set']) {
const { chats, contacts, messages, isLatest, progress, syncType } =
events['messaging-history.set']
if (syncType === proto.HistorySync.HistorySyncType.ON_DEMAND) {
logger.debug(messages, 'received on-demand history sync')
}
logger.debug(
{
contacts: contacts.length,
chats: chats.length,
messages: messages.length,
isLatest,
progress,
syncType: syncType?.toString(),
},
'messaging-history.set event fired',
)
}
// ── Incoming messages ──────────────────────────────────────────────
if (events['messages.upsert']) {
const upsert = events['messages.upsert']
logger.debug(upsert, 'messages.upsert fired')
if (upsert.requestId) {
logger.debug(upsert, 'placeholder request message received')
}
if (upsert.type === 'notify') {
for (const msg of upsert.messages) {
const text =
msg.message?.conversation ||
msg.message?.extendedTextMessage?.text
if (text === 'requestPlaceholder' && !upsert.requestId) {
const messageId = await sock.requestPlaceholderResend(msg.key)
logger.debug({ id: messageId }, 'requested placeholder resync')
}
if (text === 'onDemandHistSync') {
const messageId = await sock.fetchMessageHistory(
50,
msg.key,
msg.messageTimestamp!,
)
logger.debug({ id: messageId }, 'requested on-demand history resync')
}
if (
!msg.key.fromMe &&
doReplies &&
!isJidNewsletter(msg.key?.remoteJid!)
) {
const id = generateMessageIDV2(sock.user?.id)
logger.debug({ id, orig_id: msg.key.id }, 'replying to message')
await sock.sendMessage(
msg.key.remoteJid!,
{ text: 'pong ' + msg.key.id },
{ messageId: id },
)
}
}
}
}
// ── Message status updates ─────────────────────────────────────────
if (events['messages.update']) {
logger.debug(events['messages.update'], 'messages.update fired')
for (const { key, update } of events['messages.update']) {
if (update.pollUpdates) {
const pollCreation: proto.IMessage = {}
if (pollCreation) {
console.log(
'got poll update, aggregation: ',
getAggregateVotesInPollMessage({
message: pollCreation,
pollUpdates: update.pollUpdates,
}),
)
}
}
}
}
if (events['message-receipt.update']) {
logger.debug(events['message-receipt.update'])
}
if (events['contacts.upsert']) {
logger.debug(events['contacts.upsert'])
}
if (events['messages.reaction']) {
logger.debug(events['messages.reaction'])
}
if (events['presence.update']) {
logger.debug(events['presence.update'])
}
if (events['chats.update']) {
logger.debug(events['chats.update'])
}
if (events['contacts.update']) {
for (const contact of events['contacts.update']) {
if (typeof contact.imgUrl !== 'undefined') {
const newUrl =
contact.imgUrl === null
? null
: await sock!.profilePictureUrl(contact.id!).catch(() => null)
logger.debug(
{ id: contact.id, newUrl },
'contact has a new profile pic',
)
}
}
}
if (events['chats.delete']) {
logger.debug('chats deleted ', events['chats.delete'])
}
if (events['group.member-tag.update']) {
logger.debug(
'group member tag update',
JSON.stringify(events['group.member-tag.update'], undefined, 2),
)
}
})
return sock
async function getMessage(
key: WAMessageKey,
): Promise<WAMessageContent | undefined> {
// Implement a way to retrieve messages that were upserted from messages.upsert.
// This is left to you — use a store, database, or in-memory cache.
return proto.Message.create({ conversation: 'test' })
}
}
startSock()Interactive messages use proto.Message.InteractiveMessage with a NativeFlowMessage
that contains an array of NativeFlowButton entries. Each button has a name that
determines its type and a buttonParamsJson with type-specific parameters.
After building the protobuf message, you must relay it with special additionalNodes
(see Section 5) so WhatsApp renders the
buttons properly.
name |
Description | Key params |
|---|---|---|
quick_reply |
Inline reply button | display_text, id |
cta_url |
Opens a URL | display_text, url, merchant_url |
cta_copy |
Copies text to clipboard | display_text, copy_code |
cta_call |
Starts a phone call | display_text, phone_number |
single_select |
List / dropdown selection | title, sections[].rows[] |
cta_catalog |
Opens business catalog | business_phone_number |
send_location |
Requests user location | display_text |
import {
proto,
generateWAMessageFromContent,
isJidGroup,
type WASocket,
} from 'baileys'
import type { BinaryNode } from 'baileys'
/**
* Sends an interactive message with native-flow buttons to a WhatsApp chat.
*
* @param sock - Active Baileys WASocket instance
* @param jid - Destination JID (e.g. "5511999999999@s.whatsapp.net")
* @param userJid - Bot's own JID (sock.user.id normalized)
*/
async function sendInteractiveButtons(
sock: WASocket,
jid: string,
userJid: string,
): Promise<void> {
// ── 1. Define the buttons ────────────────────────────────────────────
const nativeFlowButtons = [
{
name: 'quick_reply',
buttonParamsJson: JSON.stringify({
display_text: 'Confirm Order',
id: 'confirm_order_1',
}),
},
{
name: 'cta_url',
buttonParamsJson: JSON.stringify({
display_text: 'Visit Website',
url: 'https://example.com',
merchant_url: 'https://example.com',
}),
},
{
name: 'cta_copy',
buttonParamsJson: JSON.stringify({
display_text: 'Copy Promo Code',
copy_code: 'SAVE20',
}),
},
{
name: 'cta_call',
buttonParamsJson: JSON.stringify({
display_text: 'Call Support',
phone_number: '+5511999999999',
}),
},
{
name: 'single_select',
buttonParamsJson: JSON.stringify({
title: 'Choose a Size',
sections: [
{
title: 'Available Sizes',
highlight_label: 'Most Popular',
rows: [
{
header: 'Small',
title: 'Small (S)',
description: 'Fits chest 34-36"',
id: 'size_s',
},
{
header: 'Medium',
title: 'Medium (M)',
description: 'Fits chest 38-40"',
id: 'size_m',
},
{
header: 'Large',
title: 'Large (L)',
description: 'Fits chest 42-44"',
id: 'size_l',
},
],
},
],
}),
},
]
// ── 2. Build the InteractiveMessage protobuf ─────────────────────────
const interactiveMessage = proto.Message.InteractiveMessage.create({
header: proto.Message.InteractiveMessage.Header.create({
title: 'Welcome to Our Store',
subtitle: 'Spring Collection 2025',
}),
body: proto.Message.InteractiveMessage.Body.create({
text: 'Browse our latest products and place your order directly here!',
}),
footer: proto.Message.InteractiveMessage.Footer.create({
text: 'Powered by Baileys',
}),
nativeFlowMessage:
proto.Message.InteractiveMessage.NativeFlowMessage.create({
buttons: nativeFlowButtons.map((b) =>
proto.Message.InteractiveMessage.NativeFlowMessage.NativeFlowButton.create(
{
name: b.name,
buttonParamsJson: b.buttonParamsJson,
},
),
),
messageParamsJson: '{}',
messageVersion: 1,
}),
})
// ── 3. Wrap in a WAMessage via generateWAMessageFromContent ──────────
const waMessage = generateWAMessageFromContent(
jid,
{ interactiveMessage },
{ userJid },
)
// ── 4. Build the biz relay nodes ─────────────────────────────────────
const bizNode = buildMixedNativeFlowBizNode()
const botNode: BinaryNode = { tag: 'bot', attrs: { biz_bot: '1' } }
// Groups only get the biz node; private chats also get the bot node.
const additionalNodes: BinaryNode[] = isJidGroup(jid)
? [bizNode]
: [botNode, bizNode]
// ── 5. Relay the message ─────────────────────────────────────────────
await sock.relayMessage(jid, waMessage.message!, {
messageId: waMessage.key.id!,
additionalNodes,
})
console.log('Interactive message sent:', waMessage.key.id)
}The PIX button uses the payment_info native-flow button. It requires a special biz
node with native_flow_name: 'payment_info' in its attributes.
key_type |
Description | Example key |
|---|---|---|
EMAIL |
E-mail address | store@example.com |
PHONE |
Phone number with country code | +5511999999999 |
CPF |
Brazilian CPF | 12345678900 |
EVP |
Random UUID key | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
import {
proto,
generateWAMessageFromContent,
isJidGroup,
type WASocket,
} from 'baileys'
import type { BinaryNode } from 'baileys'
/**
* Sends an interactive PIX payment button.
*
* @param sock - Active Baileys WASocket instance
* @param jid - Destination JID
* @param userJid - Bot's own JID
*/
async function sendPixPaymentButton(
sock: WASocket,
jid: string,
userJid: string,
): Promise<void> {
// ── 1. Build the payment_info button ─────────────────────────────────
const pixButton = {
name: 'payment_info',
buttonParamsJson: JSON.stringify({
payment_settings: [
{
type: 'pix_static_code',
pix_static_code: {
merchant_name: 'Acme Store',
key: 'payments@acme-store.com',
key_type: 'EMAIL',
},
},
],
}),
}
// ── 2. Build the InteractiveMessage protobuf ─────────────────────────
const interactiveMessage = proto.Message.InteractiveMessage.create({
body: proto.Message.InteractiveMessage.Body.create({
text: 'Pay via PIX to complete your purchase.',
}),
footer: proto.Message.InteractiveMessage.Footer.create({
text: 'Acme Store - Order #12345',
}),
nativeFlowMessage:
proto.Message.InteractiveMessage.NativeFlowMessage.create({
buttons: [
proto.Message.InteractiveMessage.NativeFlowMessage.NativeFlowButton.create(
{
name: pixButton.name,
buttonParamsJson: pixButton.buttonParamsJson,
},
),
],
messageParamsJson: '{}',
messageVersion: 1,
}),
})
// ── 3. Wrap in a WAMessage ───────────────────────────────────────────
const waMessage = generateWAMessageFromContent(
jid,
{ interactiveMessage },
{ userJid },
)
// ── 4. Build the biz relay node with payment_info flow name ──────────
//
// PIX messages need the `native_flow_name` attribute set to 'payment_info'
// on the biz node — this is different from regular interactive buttons
// which use the nested <interactive> child element.
const bizNode = buildPaymentBizNode('payment_info')
const botNode: BinaryNode = { tag: 'bot', attrs: { biz_bot: '1' } }
const additionalNodes: BinaryNode[] = isJidGroup(jid)
? [bizNode]
: [botNode, bizNode]
// ── 5. Relay ─────────────────────────────────────────────────────────
await sock.relayMessage(jid, waMessage.message!, {
messageId: waMessage.key.id!,
additionalNodes,
})
console.log('PIX payment message sent:', waMessage.key.id)
}The review_and_pay button displays an order summary with line items, totals, and a
payment action. Like PIX, it uses a special biz node — but with
native_flow_name: 'order_details'.
Amounts use a value + offset pair. The real amount is value / (10 ^ offset).
value |
offset |
Real Amount |
|---|---|---|
"1000" |
"100" |
R$ 10.00 |
"2599" |
"100" |
R$ 25.99 |
"100000" |
"100" |
R$ 1,000.00 |
import {
proto,
generateWAMessageFromContent,
isJidGroup,
type WASocket,
} from 'baileys'
import type { BinaryNode } from 'baileys'
/**
* Sends a "review and pay" interactive order message.
*
* @param sock - Active Baileys WASocket instance
* @param jid - Destination JID
* @param userJid - Bot's own JID
*/
async function sendReviewAndPayButton(
sock: WASocket,
jid: string,
userJid: string,
): Promise<void> {
// ── 1. Build the review_and_pay button ───────────────────────────────
const payButton = {
name: 'review_and_pay',
buttonParamsJson: JSON.stringify({
currency: 'BRL',
total_amount: { value: '5998', offset: '100' },
reference_id: 'order_ref_20250308_001',
type: 'physical-goods',
payment_method: 'confirm',
payment_status: 'captured',
payment_timestamp: Math.floor(Date.now() / 1000),
order: {
status: 'completed',
description: 'Acme Store - Order #001',
subtotal: { value: '5998', offset: '100' },
order_type: 'PAYMENT_REQUEST',
items: [
{
retailer_id: 'sku_tshirt_m',
name: 'Premium T-Shirt (M)',
amount: { value: '2999', offset: '100' },
quantity: '1',
},
{
retailer_id: 'sku_cap_one',
name: 'Baseball Cap',
amount: { value: '2999', offset: '100' },
quantity: '1',
},
],
},
additional_note: 'Thank you for your purchase!',
native_payment_methods: [],
share_payment_status: false,
}),
}
// ── 2. Build the InteractiveMessage protobuf ─────────────────────────
const interactiveMessage = proto.Message.InteractiveMessage.create({
body: proto.Message.InteractiveMessage.Body.create({
text: 'Your order is ready for payment.',
}),
footer: proto.Message.InteractiveMessage.Footer.create({
text: 'Acme Store',
}),
nativeFlowMessage:
proto.Message.InteractiveMessage.NativeFlowMessage.create({
buttons: [
proto.Message.InteractiveMessage.NativeFlowMessage.NativeFlowButton.create(
{
name: payButton.name,
buttonParamsJson: payButton.buttonParamsJson,
},
),
],
messageParamsJson: '{}',
messageVersion: 1,
}),
})
// ── 3. Wrap in a WAMessage ───────────────────────────────────────────
const waMessage = generateWAMessageFromContent(
jid,
{ interactiveMessage },
{ userJid },
)
// ── 4. Build the biz relay node with order_details flow name ─────────
//
// review_and_pay uses native_flow_name 'order_details' (not 'review_and_pay').
const bizNode = buildPaymentBizNode('order_details')
const botNode: BinaryNode = { tag: 'bot', attrs: { biz_bot: '1' } }
const additionalNodes: BinaryNode[] = isJidGroup(jid)
? [bizNode]
: [botNode, bizNode]
// ── 5. Relay ─────────────────────────────────────────────────────────
await sock.relayMessage(jid, waMessage.message!, {
messageId: waMessage.key.id!,
additionalNodes,
})
console.log('Review & pay message sent:', waMessage.key.id)
}All interactive messages must include a biz node in additionalNodes when calling
sock.relayMessage(). The node structure varies depending on the message type.
Three attributes are always required:
| Attribute | Value | Purpose |
|---|---|---|
actual_actors |
'2' |
Indicates a business actor |
host_storage |
'2' |
Storage mode |
privacy_mode_ts |
<computed> |
Current unix timestamp minus a fixed offset |
const PRIVACY_MODE_TS_OFFSET = 77980457
function getPrivacyModeTs(): string {
return (Math.floor(Date.now() / 1000) - PRIVACY_MODE_TS_OFFSET).toString()
}import type { BinaryNode } from 'baileys'
function createBaseBizAttrs(): Record<string, string> {
return {
actual_actors: '2',
host_storage: '2',
privacy_mode_ts: getPrivacyModeTs(),
}
}
/**
* Biz node for regular interactive buttons (quick_reply, cta_url, etc.)
* and single_select lists.
*
* Uses a nested <interactive> element with type 'native_flow' and name 'mixed'.
*/
function buildMixedNativeFlowBizNode(): BinaryNode {
return {
tag: 'biz',
attrs: createBaseBizAttrs(),
content: [
{
tag: 'interactive',
attrs: { type: 'native_flow', v: '1' },
content: [
{
tag: 'native_flow',
attrs: { v: '9', name: 'mixed' },
},
],
},
{
tag: 'quality_control',
attrs: { source_type: 'third_party' },
},
],
}
}
/**
* Biz node for payment-related buttons (PIX, review_and_pay).
*
* Instead of a nested <interactive> element, these use a flat
* `native_flow_name` attribute directly on the <biz> tag.
*
* @param flowName - 'payment_info' for PIX, 'order_details' for review_and_pay
*/
function buildPaymentBizNode(flowName: string): BinaryNode {
return {
tag: 'biz',
attrs: {
...createBaseBizAttrs(),
native_flow_name: flowName,
},
}
}| Message Type | Builder | native_flow_name |
|---|---|---|
| Interactive buttons (quick_reply, cta_url, etc.) | buildMixedNativeFlowBizNode() |
N/A (uses nested XML) |
| PIX payment | buildPaymentBizNode('payment_info') |
payment_info |
| Review & pay | buildPaymentBizNode('order_details') |
order_details |
When relaying messages, the additionalNodes array differs by chat type:
import { isJidGroup } from 'baileys'
const bizNode = buildMixedNativeFlowBizNode() // or buildPaymentBizNode(...)
const botNode: BinaryNode = { tag: 'bot', attrs: { biz_bot: '1' } }
const additionalNodes: BinaryNode[] = isJidGroup(jid)
? [bizNode] // Groups: only the biz node
: [botNode, bizNode] // Private chats: bot node + biz node1. Define buttons array → [{ name, buttonParamsJson }, ...]
2. Build InteractiveMessage → proto.Message.InteractiveMessage.create({ ... })
3. Wrap with WAMessage → generateWAMessageFromContent(jid, { interactiveMessage }, { userJid })
4. Build biz node → buildMixedNativeFlowBizNode() or buildPaymentBizNode(flowName)
5. Determine additionalNodes → isJidGroup(jid) ? [bizNode] : [botNode, bizNode]
6. Relay → sock.relayMessage(jid, message, { messageId, additionalNodes })