Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save Iucasmaia/7e9b39235e77dfaeb15f65d95b224090 to your computer and use it in GitHub Desktop.

Select an option

Save Iucasmaia/7e9b39235e77dfaeb15f65d95b224090 to your computer and use it in GitHub Desktop.
Baileys Interactive Buttons & PIX Reference

Baileys Interactive Buttons & PIX Reference

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".


Table of Contents

  1. Connection Setup (Official Example)
  2. Sending Interactive Buttons (Native Flow)
  3. Sending a PIX Payment Button
  4. Sending a Review & Pay Button
  5. Utility: Building the biz Relay Node

1. Connection Setup (Official Example)

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()

2. Sending Interactive Buttons (Native Flow)

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.

Supported Button Types

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

Full Working Example

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)
}

3. Sending a PIX Payment Button

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.

PIX Key Types

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

Full Working Example

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)
}

4. Sending a Review & Pay Button

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'.

Amount Format

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

Full Working Example

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)
}

5. Utility: Building the biz Relay Node

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

Privacy Mode Timestamp

const PRIVACY_MODE_TS_OFFSET = 77980457

function getPrivacyModeTs(): string {
  return (Math.floor(Date.now() / 1000) - PRIVACY_MODE_TS_OFFSET).toString()
}

Node Builders

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,
    },
  }
}

Which Builder to Use

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

Private Chat vs Group

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 node

Quick Reference: Complete Message Flow

1. 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 })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment