
Build a WhatsApp chatbot with code, on your own server
A developer's guide to building a WhatsApp chatbot without a no-code builder — signed webhooks, a real state machine, interactive buttons and lists, LLM replies, and handing off to a human when the bot runs out of road.
Build a WhatsApp chatbot with code, on your own server
Search for "WhatsApp chatbot" and you get drag-and-drop builders. That is the right answer for a lot of people — if your bot is a five-node FAQ tree and nobody on the team writes code, a visual builder will get you live this afternoon and you should stop reading.
This guide is for the other reader: you write code, your bot needs to talk to your database, and you would rather own the logic than express it as boxes on someone else's canvas. It builds a working WhatsApp chatbot in Node against a self-hosted WhatsApp API — no BSP, no per-message billing, no vendor holding your conversation history.
We'll use WaSphere as the gateway. It's MIT-licensed, runs as a few Docker services, and speaks plain HTTP in both directions.
The shape of it
A WhatsApp chatbot is three moving parts, and only one is interesting:
inbound ──▶ signed webhook ──▶ verify ──▶ state machine ──▶ your logic
outbound ◀── POST /messages/{type} ◀──────────────────────────┘
The gateway handles the WhatsApp protocol. You handle what to say. Everything below is about the middle box.
Prerequisites
- A WhatsApp gateway running with a connected number. WaSphere's Quick Start is clone, set secrets,
docker compose up -d, scan a QR code. - An API key with the
messages:sendscope. See API keys. - Node 20+ and a public HTTPS endpoint (ngrok is fine while developing).
Step 1 — Receive messages, and verify them
Register a webhook pointed at your bot:
curl -X POST "https://api.yourdomain.com/workspaces/{workspaceId}/webhooks" \
-H "Authorization: Bearer wsk_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "chatbot",
"url": "https://bot.yourdomain.com/hooks/whatsapp",
"events": ["message.received"],
"retryMax": 3,
"isActive": true
}'
Subscribe narrowly. WaSphere emits a lot more than inbound messages — delivery receipts, message edits and revokes, presence, calls, group participant changes, session lifecycle, bulk job results. Every event you don't need is an execution you pay for in CPU and log noise.
Each delivery carries three headers:
X-WaSphere-Event: message.received
X-WaSphere-Signature: v1,sha256=<hmac-hex>
X-WaSphere-Timestamp: 1748168400
The signature is HMAC-SHA256 over the string {timestamp}.{rawBody} using the WA Server's global WEBHOOK_SIGNING_SECRET. Verify it over the raw bytes, before any JSON parsing — re-serialized JSON will not hash to the same value, and that single mistake accounts for most "signature never matches" bug reports.
import express from 'express';
import crypto from 'crypto';
const app = express();
const SECRET = process.env.WASPHERE_WEBHOOK_SECRET;
app.post(
'/hooks/whatsapp',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-wasphere-signature'];
const ts = req.headers['x-wasphere-timestamp'];
if (!sig || !ts) return res.sendStatus(401);
// replay guard
if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) {
return res.sendStatus(401);
}
const raw = req.body.toString();
const expected =
'v1,sha256=' +
crypto.createHmac('sha256', SECRET).update(`${ts}.${raw}`).digest('hex');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// ACK immediately, then work
res.sendStatus(200);
handle(JSON.parse(raw)).catch(console.error);
},
);
That res.sendStatus(200) before handle() is not a stylistic choice. WaSphere marks a delivery failed if you take longer than 10 seconds, then retries after 1s, 5s and 30s. A bot that calls an LLM synchronously and answers slowly will receive the same message four times and advance its state machine four steps. Acknowledge first, process after.
The payload you're parsing:
{
"event": "message.received",
"sessionId": "support",
"deliveryId": "abc123",
"data": {
"messageId": "3EB0C767D097B7C7A5C1",
"from": "923001234567@s.whatsapp.net",
"fromName": "Alice",
"type": "text",
"isGroup": false,
"content": { "text": "where is my order" }
}
}
Step 2 — Send messages
One endpoint shape, fourteen message types:
POST /workspaces/{workspaceId}/proxy/api/sessions/{sessionId}/messages/{type}
const BASE = `https://api.yourdomain.com/workspaces/${process.env.WORKSPACE_ID}`;
const SESSION = process.env.WA_SESSION;
async function send(type, body) {
const r = await fetch(`${BASE}/proxy/api/sessions/${SESSION}/messages/${type}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WASPHERE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (r.status === 429) {
const wait = Number(r.headers.get('retry-after') ?? 5);
await new Promise((res) => setTimeout(res, wait * 1000));
return send(type, body);
}
if (!r.ok) throw new Error(`send ${type} failed: ${r.status}`);
return r.json(); // { messageId, status }
}
const text = (to, body) => send('text', { to, text: body });
to is international format, digits only, no +: 923001234567. The rate limit is 60 requests per minute per key, returned as 429 with a Retry-After header — the retry above is the minimum viable handling of it.
Step 3 — The state machine
This is the part a visual builder gives you for free and the part worth writing yourself, because it is where your bot stops being a FAQ and starts touching your systems.
Keep it boring: a per-contact state record and a table of handlers.
const store = new Map(); // phone -> { step, data } — use Redis or Postgres in production
const flow = {
async start(ctx) {
await send('buttons', {
to: ctx.phone,
text: `Hi ${ctx.name || 'there'} — what can I help with?`,
footer: 'Tap an option',
buttons: [
{ id: 'order', text: 'Order status' },
{ id: 'returns', text: 'Returns' },
{ id: 'human', text: 'Talk to a person' },
],
});
return 'awaiting_choice';
},
async awaiting_choice(ctx) {
switch (ctx.input) {
case 'order':
await text(ctx.phone, 'What is your order number?');
return 'awaiting_order_number';
case 'returns':
await text(ctx.phone, 'Returns are open for 30 days. Send your order number.');
return 'awaiting_order_number';
case 'human':
await escalate(ctx);
return 'with_human';
default:
return 'start'; // anything unrecognised: re-ask
}
},
async awaiting_order_number(ctx) {
const order = await db.findOrder(ctx.input.trim());
if (!order) {
await text(ctx.phone, "I couldn't find that order. Check the number and send it again.");
return 'awaiting_order_number';
}
await text(ctx.phone, `Order *${order.id}* is *${order.status}*. ETA ${order.eta}.`);
return 'start';
},
async with_human(ctx) {
return 'with_human'; // bot stays quiet; a person is handling it
},
};
async function handle(payload) {
const { data, deliveryId } = payload;
if (data.isGroup) return;
if (await seen(deliveryId)) return; // idempotency — deliveries are at-least-once
const phone = data.from.split('@')[0];
const input = data.content?.buttonId ?? data.content?.text ?? '';
const state = store.get(phone) ?? { step: 'start' };
const ctx = { phone, name: data.fromName, input, data };
const next = await flow[state.step](ctx);
store.set(phone, { ...state, step: next });
}
Three things this gets right that quick tutorials get wrong.
Idempotency. deliveryId is on every payload for exactly this reason. Delivery is at-least-once by design; if your bot writes to a database or charges a card, dedupe or you will do it twice.
A default branch. Real users type "hi" when you asked for an order number, send a voice note, or reply to a message from three days ago. Every state needs a fallback that re-asks rather than throwing.
Buttons carry ids, not labels. A tapped button comes back as a message.received with the button's id. Branch on order, not on the string "Order status" — labels get reworded, ids don't.
For more than three options use a list message: sections with selectable rows, and the selected row's id comes back the same way.
Step 4 — Add an LLM without letting it run the bot
The useful pattern is not "LLM answers everything". It's: deterministic flow for anything transactional, LLM for the open-ended tail, with the model's output constrained.
async function fallback(ctx) {
const reply = await llm({
system:
'You answer questions about ACME shipping and returns policy. ' +
'If the question needs account data, reply exactly: ESCALATE.',
user: ctx.input,
});
if (reply.trim() === 'ESCALATE') return escalate(ctx);
await text(ctx.phone, reply);
}
Order lookups, refunds and address changes stay in code, where they're testable and can't be talked into giving a discount. The model handles "do you ship to Karachi", which is the actual long tail.
Step 5 — Hand off to a human
Every bot needs an exit. The minimum version is a state — with_human above — in which the bot simply stops replying, plus a notification to whoever picks it up.
WaSphere ships a real-time team inbox, so the handoff can be: set the state, tag the contact, and let an agent take the thread in the dashboard. The contact book is the natural place to keep that context — a savedName, tags like Escalated or VIP, and a free-form note the agent reads before replying:
curl -X POST "https://api.yourdomain.com/workspaces/{workspaceId}/contacts" \
-H "Authorization: Bearer wsk_your_key" \
-H "Content-Type: application/json" \
-d '{ "phone": "923001234567", "savedName": "Alice", "tags": ["Escalated"] }'
If several people staff the bot's overflow, roles are enforced server-side — an agent without the api_keys capability cannot reach those endpoints even by calling them directly, rather than merely having the button hidden.
What to know before you ship
Pacing is deliberate. New sessions pause a random 4–12 seconds before every outgoing message, because perfectly uniform timing is the clearest machine signature there is. Your bot will feel slower than a web chat widget. That's the trade. You can tune it in anti-ban controls — faster is always riskier.
Ban risk is real. This runs on WhatsApp's unofficial web protocol. Free, no 24-hour window, no template approval — and the number can be blocked. Nothing eliminates that. Message people who opted in, warm new numbers slowly, give an easy way out. If you cannot tolerate a ban, run the session on the Meta Cloud API instead — same endpoints, same webhooks, per-conversation pricing, and templates required outside the 24-hour window. You can run one number on each in the same workspace.
Sessions drop. 500 on send usually means the session disconnected. Subscribe to session.disconnected and alert on it; a silently dead bot is worse than one that says it's down.
Next
- Send messages — all fourteen types with cURL and Node
- Webhooks — full event list, payloads, retry policy
- Connect WhatsApp to n8n — the same bot as a workflow, if you'd rather not run a service
- Venom Bot vs WaSphere — if you're choosing a library instead of a gateway
The whole thing is open source. Read it before you point it at your customers' phone numbers.