cd /blog
GET/blog/building-whatsapp-chatbot-complete-tutorial200OK
nodejstutorialchatbot

Building a WhatsApp Chatbot: Complete Tutorial

March 28, 2026|10 min read

WhatsApp chatbots let businesses automate customer support, bookings, order tracking, and lead qualification — all inside the messaging app your customers already use. This tutorial walks you through building a production-ready WhatsApp chatbot with Node.js, Express, and the WSAPI REST API.

What You'll Build

By the end of this tutorial, you'll have a chatbot that:

  • Responds to incoming WhatsApp messages via webhooks
  • Navigates a multi-step conversation menu
  • Maintains session state across messages
  • Sends rich media responses (images, documents)
  • Handles errors gracefully

Prerequisites

  • Node.js 18+ and npm/pnpm
  • A WSAPI Cloud account — start a free trial
  • A WhatsApp account connected via QR code in the dashboard
  • A publicly accessible server (or ngrok for local development)

Project Setup

terminal
mkdir whatsapp-bot && cd whatsapp-bot
npm init -y
npm install express

Step 1: Webhook Receiver

The foundation of any chatbot is receiving messages. Create an Express server that listens for webhook events from WSAPI:

index.js
import express from 'express';

const app = express();
app.use(express.json());

const API = 'https://api.wsapi.chat';
const HEADERS = {
  'x-api-key': process.env.WSAPI_KEY,
  'x-instance-id': process.env.INSTANCE_ID,
  'Content-Type': 'application/json'
};

async function sendText(to, text) {
  await fetch(`${API}/message/text`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ to, text })
  });
}

app.post('/webhook', async (req, res) => {
  const { event, data } = req.body;

  if (event === 'message' && !data.fromMe) {
    await handleMessage(data);
  }

  res.sendStatus(200);
});

app.listen(3000, () => console.log('Bot running on port 3000'));

Step 2: Conversation State Machine

A chatbot needs to remember where each user is in the conversation. We'll use an in-memory Map for session state (use Redis or a database in production):

state.js
const sessions = new Map();

function getSession(userId) {
  if (!sessions.has(userId)) {
    sessions.set(userId, { state: 'welcome', data: {} });
  }
  return sessions.get(userId);
}

function setState(userId, state, data = {}) {
  const session = getSession(userId);
  session.state = state;
  session.data = { ...session.data, ...data };
}

Step 3: Message Router

Route incoming messages to the right handler based on the user's current state:

handler.js
async function handleMessage(data) {
  const userId = data.from;
  const text = data.body.trim();
  const session = getSession(userId);

  switch (session.state) {
    case 'welcome':
      await sendText(userId,
        '👋 Welcome to Acme Support!\n\n' +
        'How can I help you today?\n\n' +
        '1️⃣ Track an order\n' +
        '2️⃣ Product information\n' +
        '3️⃣ Talk to a human'
      );
      setState(userId, 'main_menu');
      break;

    case 'main_menu':
      await handleMainMenu(userId, text);
      break;

    case 'awaiting_order_number':
      await handleOrderLookup(userId, text);
      break;

    case 'awaiting_product_choice':
      await handleProductChoice(userId, text);
      break;

    default:
      setState(userId, 'welcome');
      await handleMessage(data);
  }
}

async function handleMainMenu(userId, text) {
  switch (text) {
    case '1':
      await sendText(userId, 'Please enter your order number:');
      setState(userId, 'awaiting_order_number');
      break;
    case '2':
      await sendText(userId,
        'Which product are you interested in?\n\n' +
        'A. Starter Kit — $29\n' +
        'B. Pro Bundle — $79\n' +
        'C. Enterprise — Custom'
      );
      setState(userId, 'awaiting_product_choice');
      break;
    case '3':
      await sendText(userId,
        'Connecting you to a support agent...\n' +
        'Average wait time: 2 minutes.'
      );
      setState(userId, 'welcome');
      break;
    default:
      await sendText(userId, 'Please reply with 1, 2, or 3.');
  }
}

Step 4: Business Logic Handlers

Implement the actual business logic for each conversation branch:

handlers.js
async function handleOrderLookup(userId, orderNumber) {
  // In production, query your database or order API
  const order = await lookupOrder(orderNumber);

  if (order) {
    await sendText(userId,
      `📦 Order #${orderNumber}\n\n` +
      `Status: ${order.status}\n` +
      `Estimated delivery: ${order.eta}\n\n` +
      'Type "menu" to go back.'
    );
  } else {
    await sendText(userId,
      `Order #${orderNumber} not found.\n` +
      'Please check the number and try again, or type "menu".'
    );
  }
  setState(userId, 'welcome');
}

async function handleProductChoice(userId, text) {
  const products = {
    a: { name: 'Starter Kit', price: '$29/mo', url: 'https://example.com/starter.jpg' },
    b: { name: 'Pro Bundle', price: '$79/mo', url: 'https://example.com/pro.jpg' },
    c: { name: 'Enterprise', price: 'Custom', url: 'https://example.com/enterprise.jpg' },
  };

  const product = products[text.toLowerCase()];
  if (!product) {
    await sendText(userId, 'Please reply with A, B, or C.');
    return;
  }

  // Send product image
  await fetch(`${API}/message/image`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({
      to: userId,
      url: product.url,
      caption: `${product.name} — ${product.price}`
    })
  });

  await sendText(userId,
    `Want to learn more about ${product.name}?\n` +
    'Visit https://example.com or type "menu" to go back.'
  );
  setState(userId, 'welcome');
}

Step 5: Media Responses

Chatbots that send images, documents, and locations feel more engaging. WSAPI makes this easy — just change the endpoint:

media.js
// Send an image
async function sendImage(to, url, caption) {
  await fetch(`${API}/message/image`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ to, url, caption })
  });
}

// Send a document (PDF, etc.)
async function sendDocument(to, url, filename) {
  await fetch(`${API}/message/document`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({ to, url, filename })
  });
}

// Send a location pin
async function sendLocation(to, lat, lng, description) {
  await fetch(`${API}/message/location`, {
    method: 'POST',
    headers: HEADERS,
    body: JSON.stringify({
      to, latitude: lat, longitude: lng, description
    })
  });
}

Production Deployment Checklist

  1. Use persistent storage — Replace the in-memory Map with Redis or a database for session state
  2. Add webhook signature verification — Verify the x-signature header to ensure requests come from WSAPI
  3. Handle timeouts — Set conversation timeouts to reset stale sessions (e.g., 30 minutes of inactivity)
  4. Add logging — Log all incoming messages and bot responses for debugging and analytics
  5. Rate limiting — Protect your webhook endpoint from abuse
  6. Error recovery — Send a friendly fallback message if your handler throws an error
  7. Use environment variables — Never hardcode API keys

Next Steps