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 --initCreate your environment file:
# .env
WHATSAPP_TOKEN=your_access_token
WHATSAPP_PHONE_ID=your_phone_number_id
WHATSAPP_VERIFY_TOKEN=my_secret_verify_token
PORT=3000Project 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.jsonWhatsApp 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.tsExpose 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