cd /blog
GET/blog/whatsapp-chatbot-api-buttons-nodejs—200OK
tutorialchatbotnodejs

WhatsApp Chatbot API: A Button-Driven Bot in Node.js

October 19, 2026|5 min read

Most chatbots fail at the same place: they ask a question and then try to understand free text. A button-driven bot skips that problem. It offers three choices, the customer taps one, and your code receives a clean id.

This bot is about 60 lines of Node.js. It receives messages over a webhook, answers any message with a menu of buttons, and replies to the button the customer taps. There is no state machine and no database. For a bot that tracks multi-step conversations, see our complete chatbot tutorial.

What you need

Node 18 or later, one npm install express, and a WSAPI instance connected to a WhatsApp number. In the instance settings, set the delivery mode to webhook, point it at https://your-domain/webhook, and add a signing secret. While developing, a tunnel such as ngrok gives your laptop a public HTTPS address. The quickstart covers the instance setup.

The bot

bot.mjs
import express from "express";
import crypto from "node:crypto";

const API = process.env.WSAPI_API_URL ?? "https://api.wsapi.chat";
const SECRET = process.env.WSAPI_SIGNING_SECRET;

function send(path, body) {
  return fetch(API + path, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Api-Key": process.env.WSAPI_API_KEY,
      "X-Instance-Id": process.env.WSAPI_INSTANCE_ID,
    },
    body: JSON.stringify(body),
  });
}

function verify(raw, header) {
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
  return header?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

const seen = new Set();

async function handle(event) {
  if (event.eventType !== "message" || seen.has(event.eventId)) return;
  seen.add(event.eventId);

  const msg = event.eventData;
  if (msg.sender.isMe) return;

  if (msg.type === "button_reply") {
    const answers = {
      hours: "We are open Monday to Friday, 9:00 to 18:00.",
      order: "Send us your order number and we will look it up.",
      human: "A person from our team will write to you shortly.",
    };
    await send("/messages/text", { to: msg.chatId, text: answers[msg.buttonReply.id] ?? "Sorry, I did not get that." });
    return;
  }

  await send("/messages/buttons", {
    to: msg.chatId,
    text: "Hi! How can we help?",
    buttons: [
      { id: "hours", text: "Opening hours" },
      { id: "order", text: "Track my order" },
      { id: "human", text: "Talk to a person" },
    ],
  });
}

const app = express();

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.header("X-Webhook-Signature"))) return res.sendStatus(401);
  res.sendStatus(200);
  handle(JSON.parse(req.body)).catch(console.error);
});

app.listen(3000, () => console.log("Bot listening on :3000"));

How it works

Verify first. Every webhook carries an X-Webhook-Signature header: an HMAC-SHA256 of the raw body, signed with your secret. The route reads the body as raw bytes, checks the signature in constant time, and rejects anything else with a 401. Without this check, anyone who finds your URL can make your bot send messages.

Answer fast. The route returns 200 before doing any work, because WSAPI retries deliveries that fail. Retries mean the same event can arrive twice, so the handler remembers each eventId and skips repeats. A production bot would keep those ids in Redis or a database instead of memory.

Ignore yourself. Your own sent messages come back as events too. The check on sender.isMe stops the bot from answering itself.

Route on ids, not text. When the customer taps a button, the event arrives with type set to button_reply and the id you chose in buttonReply.id. Labels can be translated or reworded later and the logic keeps working. Anything else, whether a greeting or a stray sentence, gets the menu again.

Keep the menu small. A message takes 1 to 3 buttons and each label can be up to 20 characters. If your bot needs more choices than that, move them into a list, which holds up to 10 rows and lets you add a short description to each one. A request that breaks a limit is rejected with a 400 that names the field, so mistakes show up in testing, not in front of customers.

Try it without a phone

You can test the whole loop with a signed curl, which also shows how little the bot depends on:

terminal
BODY='{"eventId":"evt_1","eventType":"message","eventData":{"chatId":"1234567890@s.whatsapp.net","type":"text","text":"hello","sender":{"id":"1234567890@s.whatsapp.net","isMe":false}}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WSAPI_SIGNING_SECRET" | awk '{print $2}')"
curl -X POST localhost:3000/webhook -H "Content-Type: application/json" -H "X-Webhook-Signature: $SIG" -d "$BODY"

The bot answers 200, and a button menu goes out to that chat.

Where to take it next

Replace the three canned answers with calls to your own systems: an order lookup, a booking, a ticket. Swap the buttons for a list when you have more than three options, as described in the buttons and lists guide. Pass aiLabel: true if the replies come from an AI model, so customers know.

How buttons and lists are displayed is up to WhatsApp and may change, so keep a typed fallback: this bot already shows the menu again for any text it does not understand. The free trial lasts 14 days with no credit card, and the webhooks guide covers filtering and retries in detail.

FAQ

Do I need a WhatsApp Business API account?

No. The bot runs on the account you connect to an instance.

Can WhatsApp restrict my number?

WSAPI connects a regular WhatsApp account, so WhatsApp's usage policies apply and accounts that send unsolicited bulk messages can be limited.