How I Built a WhatsApp Chatbot for My Business Using Node.js and Meta’s Cloud API
No third-party services. No monthly SaaS fees. Just the official API and a few hundred lines of JavaScript.

How I Built a WhatsApp Chatbot for My Business Using Node.js and Meta’s Cloud API
No third-party services. No monthly SaaS fees. Just the official API and a few hundred lines of JavaScript.
WhatsApp has over 2 billion active users. If your customers are anywhere in the world, there’s a good chance they’re on it. So when I decided to automate customer interactions for my business, answering FAQs, taking orders, handling bookings, WhatsApp felt like the obvious channel.
The problem? Most tutorials send you toward paid platforms like Twilio, Respond.io, or Intercom. They’re great tools, but I wanted full control and zero recurring cost at the API level. Turns out, Meta’s own WhatsApp Cloud API is free to use (you only pay per conversation at scale), well-documented, and surprisingly developer-friendly.
Here’s exactly how I built it.
What We’re Building
A Node.js webhook server that:
- Responds to incoming WhatsApp messages
- Shows an interactive main menu (quick-reply buttons)
- Handles a FAQ flow — an interactive list of questions with answers
- Handles an order/booking flow — multi-step conversation to capture and confirm an order
No AI, no LLM, no external dependencies beyond Express and Axios. Just clean, maintainable logic you can fully own.
Prerequisites
- Node.js 18+
- A Meta Developer account — developers.facebook.com
- A free ngrok account — ngrok.com
Step 1: Create a Meta App
Head to developers.facebook.com, click My Apps → Create App, and choose the Business type.
On the “Use cases” screen, select “Connect with customers through WhatsApp”. This provisions the WhatsApp Cloud API for your app — no approval needed for development.
Once created, go to WhatsApp → API Setup. You’ll find:
- A test phone number (Meta provides this for free)
- Your Phone Number ID
- A temporary access token
Copy all three — you’ll need them shortly.
Step 2: Project Setup
bash
mkdir whatsapp-bot && cd whatsapp-bot
npm init -y
npm install express axios dotenv
Create a .env file:
WHATSAPP_TOKEN=your_access_token_here
WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id_here
VERIFY_TOKEN=any_random_string_you_choose
PORT=3000
Your project structure will look like this:
whatsapp-bot/
├── index.js
├── whatsapp.js
├── handlers/
│ ├── faq.js
│ └── orders.js
└── .env
Step 3: The WhatsApp API Helper
Meta’s Cloud API is a simple REST API. Create whatsapp.js to wrap the three message types we'll use:
js
const axios = require("axios");
const BASE_URL = "https://graph.facebook.com/v19.0";
const PHONE_ID = process.env.WHATSAPP_PHONE_NUMBER_ID;
const TOKEN = process.env.WHATSAPP_TOKEN;
async function sendText(to, text) {
return _post({ messaging_product: "whatsapp", to, type: "text", text: { body: text } });
}
async function sendList(to, headerText, bodyText, buttonLabel, sections) {
return _post({
messaging_product: "whatsapp",
to,
type: "interactive",
interactive: {
type: "list",
header: { type: "text", text: headerText },
body: { text: bodyText },
action: { button: buttonLabel, sections },
},
});
}
async function sendButtons(to, bodyText, buttons) {
return _post({
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 } })),
},
},
});
}
async function _post(payload) {
const res = await axios.post(
`${BASE_URL}/${PHONE_ID}/messages`,
payload,
{ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" } }
);
return res.data;
}
module.exports = { sendText, sendList, sendButtons };
Three functions. That’s all you need for a fully interactive bot.
Step 4: The FAQ Handler
handlers/faq.js — define your questions as a plain object and render them as a WhatsApp list menu:
js
const { sendList, sendText } = require("../whatsapp");
const FAQS = {
faq_hours: {
title: "Business Hours",
answer: "We're open Monday–Friday, 9 AM – 6 PM.",
},
faq_returns: {
title: "Return Policy",
answer: "Returns accepted within 7 days of delivery.",
},
faq_payment: {
title: "Payment Methods",
answer: "We accept Visa, Mastercard, and bank transfer.",
},
};
async function showFaqMenu(to) {
const rows = Object.entries(FAQS).map(([id, { title }]) => ({ id, title }));
await sendList(to, "❓ FAQs", "Choose a topic:", "View FAQs", [
{ title: "Frequently Asked Questions", rows },
]);
}
async function handleFaqSelection(to, rowId) {
const faq = FAQS[rowId];
if (faq) await sendText(to, `*${faq.title}*\n\n${faq.answer}`);
}
module.exports = { showFaqMenu, handleFaqSelection };
To add a new FAQ, just add a key to the FAQS object. No other changes needed.
Step 5: The Order Flow
handlers/orders.js — a simple multi-step conversation using an in-memory session store:
js
const { sendList, sendButtons, sendText } = require("../whatsapp");
const sessions = {}; // replace with Redis or a DB in production
const CATALOG = {
prod_1: { name: "Basic Package", price: "LKR 2,500" },
prod_2: { name: "Standard Package", price: "LKR 5,000" },
prod_3: { name: "Premium Package", price: "LKR 9,500" },
};
async function showOrderMenu(to) {
sessions[to] = { step: "select_product" };
const rows = Object.entries(CATALOG).map(([id, { name, price }]) => ({
id, title: name, description: price,
}));
await sendList(to, "🛒 Place an Order", "Select a product:", "Browse", [
{ title: "Our Products", rows },
]);
}
async function handleOrderStep(to, message) {
const session = sessions[to] || {};
if (session.step === "select_product" && CATALOG[message]) {
session.product = CATALOG[message];
session.step = "confirm";
sessions[to] = session;
await sendButtons(
to,
`You selected *${session.product.name}* (${session.product.price}).\n\nProceed?`,
[{ id: "order_confirm", title: "✅ Confirm" }, { id: "order_cancel", title: "❌ Cancel" }]
);
return true;
}
if (session.step === "confirm") {
if (message === "order_confirm") {
const orderId = `ORD-${Date.now()}`;
await sendText(to, `🎉 Order confirmed!\n\n*ID:* ${orderId}\n*Item:* ${session.product.name}\n*Total:* ${session.product.price}\n\nWe'll be in touch shortly.`);
} else {
await sendText(to, "Order cancelled. Feel free to browse again anytime!");
}
delete sessions[to];
return true;
}
return false;
}
module.exports = { showOrderMenu, handleOrderStep };
The session object tracks where each user is in the conversation. In production, swap sessions = {} for Redis so it survives server restarts.
Step 6: The Webhook Server
index.js — the heart of the bot. Meta sends all incoming messages to your webhook via POST:
js
require("dotenv").config();
const express = require("express");
const { sendText, sendButtons } = require("./whatsapp");
const { showFaqMenu, handleFaqSelection } = require("./handlers/faq");
const { showOrderMenu, handleOrderStep } = require("./handlers/orders");
const app = express();
app.use(express.json());
// Meta verifies your webhook with a GET request first
app.get("/webhook", (req, res) => {
const { "hub.mode": mode, "hub.verify_token": token, "hub.challenge": challenge } = req.query;
if (mode === "subscribe" && token === process.env.VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
res.sendStatus(403);
});
// All incoming messages arrive here
app.post("/webhook", async (req, res) => {
res.sendStatus(200); // acknowledge immediately or Meta will retry
try {
const msg = req.body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
if (!msg) return;
const from = msg.from;
const type = msg.type;
if (type === "text") {
const text = msg.text.body.trim().toLowerCase();
if (["hi", "hello", "hey", "start"].includes(text)) return showMainMenu(from);
if (text === "faq" || text === "help") return showFaqMenu(from);
if (["order", "buy", "book"].includes(text)) return showOrderMenu(from);
await sendText(from, "Reply *hi* to see the main menu.");
return;
}
if (type === "interactive" && msg.interactive?.type === "list_reply") {
const rowId = msg.interactive.list_reply.id;
const handled = await handleOrderStep(from, rowId);
if (!handled) await handleFaqSelection(from, rowId);
return;
}
if (type === "interactive" && msg.interactive?.type === "button_reply") {
const btnId = msg.interactive.button_reply.id;
if (btnId === "menu_faq") return showFaqMenu(from);
if (btnId === "menu_order") return showOrderMenu(from);
if (btnId === "menu_help") {
return sendText(from, "📞 *Contact Us*\n\nEmail: support@yourbusiness.com\nPhone: +94 11 234 5678");
}
await handleOrderStep(from, btnId);
}
} catch (err) {
console.error("Error:", err.response?.data ?? err.message);
}
});
async function showMainMenu(from) {
await sendButtons(from, "👋 Welcome to *Your Business*!\n\nHow can I help?", [
{ id: "menu_faq", title: "❓ FAQs" },
{ id: "menu_order", title: "🛒 Place Order" },
{ id: "menu_help", title: "📞 Contact Us" },
]);
}
app.listen(process.env.PORT || 3000, () => console.log("Bot running on port 3000"));
Step 7: Connect It to Meta
Start the bot and expose it with ngrok:
bash
npm start
# In a separate terminal
npx ngrok authtoken YOUR_NGROK_TOKEN
npx ngrok http 3000
Then in your Meta app:
- Go to WhatsApp → Configuration
- Set Callback URL to
[https://xxxx.ngrok-free.app/webhook](https://xxxx.ngrok-free.app/webhook) - Set Verify token to match your
.env - Click Verify and save
- Scroll to Webhook fields, find
**messages, toggle to Subscribed**
Step 8: Add a Test Recipient
Because your app is unpublished, Meta only allows messages to pre-approved numbers. Go to WhatsApp → API Setup → To → Add phone number, verify via OTP, and you’re good. Up to 5 numbers in dev mode.
Common Gotchas
**#131030 Recipient not in allowed list** — You need to add the recipient number as a test recipient (Step 8).
Token expired — The temporary token lasts ~24 hours. Create a permanent one via Business Settings → System users before going live.
Interactive messages not rendering — Some older WhatsApp versions don’t support interactive messages. Always have a text fallback.
Bot stops working after ngrok restart — ngrok generates a new URL each time. Update the webhook URL on Meta or use a paid ngrok plan with a fixed domain.
What’s Next
This is a solid foundation. From here you can:
- Connect a database — store orders in PostgreSQL or MongoDB instead of memory
- Add AI responses — pipe unrecognized messages to the Claude or OpenAI API for natural language answers
- Deploy to production — host on Railway or Render, add a real business phone number, and publish your Meta app
- Add message templates — for proactive notifications like order confirmations and shipping updates
The full source code is available on GitHub. If this helped you, give it a clap and drop a comment if you get stuck anywhere.
메타데이터
- post_id
- 3787eee7a0a9
- slug
- how-i-built-a-whatsapp-chatbot-for-my-business-using-node-js-and-metas-cloud-api-3787eee7a0a9
- url
- https://medium.com/@harshagunathilaka5/how-i-built-a-whatsapp-chatbot-for-my-business-using-node-js-and-metas-cloud-api-3787eee7a0a9
- canonical_url
- https://medium.com/@harshagunathilaka5/how-i-built-a-whatsapp-chatbot-for-my-business-using-node-js-and-metas-cloud-api-3787eee7a0a9
- author_url
- https://medium.com/@harshagunathilaka5
- status
- ok
- fetched_at
- 2026-06-09 15:37:30