By Bonaventure Ogeto|

Building a WhatsApp Chatbot With Node.js

You build a WhatsApp chatbot by setting up a Node.js Express server that receives messages via the Cloud API webhook, processes user intent, and responds with text, images, or interactive buttons. The bot listens on a POST endpoint, parses the incoming message, determines a response, and sends it back through the WhatsApp API. This guide builds a complete order-tracking bot from scratch.

Project setup

Start with a fresh Node.js project. We will use Express for the server, axios for API calls, and dotenv for configuration.

mkdir whatsapp-bot && cd whatsapp-bot
npm init -y
npm install express axios dotenv
npm install -D typescript @types/express @types/node ts-node
npx tsc --init

Create your environment file:

# .env
WHATSAPP_TOKEN=your_access_token
WHATSAPP_PHONE_ID=your_phone_number_id
WHATSAPP_VERIFY_TOKEN=my_secret_verify_token
PORT=3000

Project structure:

whatsapp-bot/
  src/
    index.ts         # Express server and webhook
    whatsapp.ts      # WhatsApp API helper functions
    bot.ts           # Message processing logic
  .env
  package.json
  tsconfig.json

WhatsApp API client

Create a reusable module for sending different types of messages.

src/whatsapp.ts:

import axios from 'axios';

const API_URL = 'https://graph.facebook.com/v18.0';

function getHeaders() {
  return {
    Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
    'Content-Type': 'application/json',
  };
}

export async function sendText(to: string, text: string) {
  const phoneId = process.env.WHATSAPP_PHONE_ID;
  await axios.post(
    `${API_URL}/${phoneId}/messages`,
    {
      messaging_product: 'whatsapp',
      to,
      type: 'text',
      text: { body: text },
    },
    { headers: getHeaders() }
  );
}

export async function sendButtons(
  to: string,
  bodyText: string,
  buttons: { id: string; title: string }[]
) {
  const phoneId = process.env.WHATSAPP_PHONE_ID;
  await axios.post(
    `${API_URL}/${phoneId}/messages`,
    {
      messaging_product: 'whatsapp',
      to,
      type: 'interactive',
      interactive: {
        type: 'button',
        body: { text: bodyText },
        action: {
          buttons: buttons.map(b => ({
            type: 'reply',
            reply: { id: b.id, title: b.title },
          })),
        },
      },
    },
    { headers: getHeaders() }
  );
}

export async function sendList(
  to: string,
  bodyText: string,
  buttonText: string,
  sections: {
    title: string;
    rows: { id: string; title: string; description?: string }[];
  }[]
) {
  const phoneId = process.env.WHATSAPP_PHONE_ID;
  await axios.post(
    `${API_URL}/${phoneId}/messages`,
    {
      messaging_product: 'whatsapp',
      to,
      type: 'interactive',
      interactive: {
        type: 'list',
        body: { text: bodyText },
        action: {
          button: buttonText,
          sections,
        },
      },
    },
    { headers: getHeaders() }
  );
}

This gives you three sending methods: plain text, interactive buttons (up to 3 buttons), and list menus (scrollable list of options). Interactive messages have much higher engagement than plain text because users can tap instead of typing.

Bot logic: processing messages

The bot needs to understand what the user wants and respond appropriately. For a simple bot, keyword matching works well. For complex flows, use a state machine.

src/bot.ts:

import { sendText, sendButtons, sendList } from './whatsapp';

// Simple in-memory state (use Redis in production)
const userState = new Map<string, string>();

export async function handleMessage(
  from: string,
  type: string,
  message: any
) {
  // Extract the text content
  let userText = '';
  if (type === 'text') {
    userText = message.text.body.toLowerCase().trim();
  } else if (type === 'interactive') {
    // Button or list reply
    userText = (
      message.interactive.button_reply?.id ||
      message.interactive.list_reply?.id ||
      ''
    );
  }

  const state = userState.get(from) || 'idle';

  // Main menu triggers
  if (
    userText === 'hi' ||
    userText === 'hello' ||
    userText === 'menu' ||
    userText === 'start'
  ) {
    userState.set(from, 'idle');
    await sendButtons(from, 'Welcome to McTaba Labs! How can I help?', [
      { id: 'track_order', title: 'Track Order' },
      { id: 'view_courses', title: 'View Courses' },
      { id: 'talk_human', title: 'Talk to Human' },
    ]);
    return;
  }

  // Track order flow
  if (userText === 'track_order') {
    userState.set(from, 'awaiting_order_id');
    await sendText(
      from,
      'Please type your order number (e.g., ORD-12345)'
    );
    return;
  }

  if (state === 'awaiting_order_id') {
    userState.set(from, 'idle');
    // In a real app, look up the order in your database
    const orderId = userText.toUpperCase();
    await sendText(
      from,
      `Order ${orderId}:\n` +
      `Status: Processing\n` +
      `Expected delivery: 2 to 3 business days\n\n` +
      `Type "menu" for more options.`
    );
    return;
  }

  // View courses
  if (userText === 'view_courses') {
    await sendList(
      from,
      'Here are our available courses:',
      'See Courses',
      [
        {
          title: 'Courses',
          rows: [
            {
              id: 'course_fullstack',
              title: 'Full-Stack AI Eng.',
              description: '26-week marathon program',
            },
            {
              id: 'course_mpesa',
              title: 'M-Pesa Integration',
              description: 'Daraja API, STK Push, webhooks',
            },
            {
              id: 'course_foundations',
              title: 'Tech Foundations',
              description: 'Start here if you are new',
            },
          ],
        },
      ]
    );
    return;
  }

  // Course detail responses
  if (userText.startsWith('course_')) {
    const courses: Record<string, string> = {
      course_fullstack:
        'Full-Stack AI Engineering\n' +
        '26-week intensive marathon.\n' +
        'Visit mctaba.com for details.',
      course_mpesa:
        'M-Pesa Integration\n' +
        'Learn to build with Daraja API.\n' +
        'Visit academy.mctaba.com',
      course_foundations:
        'Tech Foundations\n' +
        'Perfect starting point for beginners.\n' +
        'Visit academy.mctaba.com',
    };
    const info = courses[userText] || 'Course not found.';
    await sendText(from, info + '\n\nType "menu" for more options.');
    return;
  }

  // Talk to human
  if (userText === 'talk_human') {
    userState.set(from, 'idle');
    await sendText(
      from,
      'A team member will reach out to you shortly. " +
      'Our support hours are Mon to Fri, 9am to 5pm EAT.\n\n' +
      'Type "menu" to go back.'
    );
    // TODO: notify support team
    return;
  }

  // Default: unrecognized input
  await sendText(
    from,
    'I did not understand that. Type "menu" to see options.'
  );
}

The state management here is simple: a Map tracks where each user is in the conversation. When they click "Track Order," the state changes to awaiting_order_id. The next message they send is treated as an order number. After processing, the state resets to idle.

The Express server and webhook handler

src/index.ts:

import 'dotenv/config';
import express from 'express';
import { handleMessage } from './bot';

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

const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN!;

// Webhook verification
app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  const challenge = req.query['hub.challenge'];

  if (mode === 'subscribe' && token === VERIFY_TOKEN) {
    console.log('Webhook verified');
    res.status(200).send(challenge);
  } else {
    res.sendStatus(403);
  }
});

// Receive messages
app.post('/webhook', async (req, res) => {
  // Acknowledge immediately
  res.sendStatus(200);

  try {
    const entry = req.body.entry?.[0];
    const changes = entry?.changes?.[0];
    const value = changes?.value;

    // Skip status updates (delivered, read receipts)
    if (!value?.messages) return;

    const message = value.messages[0];
    const from = message.from;
    const type = message.type;

    await handleMessage(from, type, message);
  } catch (error) {
    console.error('Error processing webhook:', error);
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`WhatsApp bot running on port ${PORT}`);
});

Run the bot:

npx ts-node src/index.ts

Expose it with ngrok and register the webhook URL in your Meta app settings. Then send "hi" to your WhatsApp test number from one of the registered test phone numbers.

Deploying to production

Moving from test to production involves a few steps:

1. Get a permanent access token: create a System User in Meta Business Manager, generate a permanent token, and assign it WhatsApp messaging permissions. The temporary token from the developer portal expires after 24 hours.

2. Register a production phone number: add a real phone number to your WhatsApp Business account. This number will receive an SMS or voice call for verification. Once verified, it becomes your bot's production number.

3. Deploy your server: deploy to any hosting provider that supports Node.js. Railway, Render, DigitalOcean App Platform, or a VPS all work. Make sure:

  • HTTPS is configured (most platforms handle this automatically)
  • Environment variables are set securely
  • The server restarts on crashes (use PM2 or a container orchestrator)

4. Update the webhook URL: point the Meta webhook to your production domain instead of ngrok.

5. Replace in-memory state with Redis: the in-memory Map for user state disappears when your server restarts. Use Redis or a database for production state management:

import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL,
});
await redis.connect();

async function getState(phone: string): Promise<string> {
  return (await redis.get(`wa:state:${phone}`)) || 'idle';
}

async function setState(
  phone: string,
  state: string
): Promise<void> {
  // Expire after 30 minutes of inactivity
  await redis.set(`wa:state:${phone}`, state, { EX: 1800 });
}

6. Handle errors gracefully: if your bot crashes mid-conversation, the user should be able to type "menu" to restart. Always have a default catch-all response.

7. Monitor: log every incoming message and outgoing response. Track response times, error rates, and unrecognized message rates. High unrecognized rates mean users are trying to do things your bot does not support.

Frequently Asked Questions

Can I use this to send bulk marketing messages?
You can send template messages to users who have opted in, but WhatsApp has strict policies against spam. Users must have explicitly agreed to receive messages from your business. Sending unsolicited marketing messages can get your WhatsApp Business account banned. Start with transactional messages (order updates, confirmations) and add marketing only with clear opt-in.
How do I handle media messages (images, documents)?
When a user sends an image or document, the webhook includes a media ID instead of the content directly. You retrieve the actual file by calling the Media endpoint with that ID. The Cloud API supports images, documents, audio, video, stickers, and location messages. Each type has a different structure in the webhook payload.
What is the rate limit for sending messages?
New WhatsApp Business accounts start with a messaging limit of 250 unique users per 24 hours. As you maintain good quality (low block rates), Meta increases this to 1,000, then 10,000, then 100,000. The limits apply to business-initiated conversations, not replies within the 24-hour window.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon. Ship 8 production apps with M-Pesa, USSD, and WhatsApp integrations, and get career support until placement.

See Programs

Also available: M-Pesa Integration course