Building a WhatsApp Chatbot: Complete Tutorial
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
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:
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):
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:
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:
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:
// 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
- Use persistent storage — Replace the in-memory Map with Redis or a database for session state
- Add webhook signature verification — Verify the
x-signatureheader to ensure requests come from WSAPI - Handle timeouts — Set conversation timeouts to reset stale sessions (e.g., 30 minutes of inactivity)
- Add logging — Log all incoming messages and bot responses for debugging and analytics
- Rate limiting — Protect your webhook endpoint from abuse
- Error recovery — Send a friendly fallback message if your handler throws an error
- Use environment variables — Never hardcode API keys
Next Steps
- Deep dive into webhook setup including SSE streaming and signature verification
- Build the same bot in Python with FastAPI
- Add AI-powered responses by integrating OpenAI or Claude as your message handler
- Read the full API docs for all supported message types and events