The hard part of putting a chatbot on WhatsApp is not the chatbot. It is WhatsApp.
The messaging itself is a plain HTTPS POST and you will have it working in twenty minutes. Then you spend a week discovering the rules Meta's platform enforces that no other channel has: a reply window, a template approval queue, a billing model that keeps changing, and a webhook that is cheerfully lying to you about delivery.
This is the list I wish I had read first. It assumes WhatsApp Cloud API (Meta's own hosted version, no BSP), Node on the receiving end, and that you already have some kind of answer engine you want to put behind it.
1. Webhook verification is a GET, and the field you forget is hub.challenge
Meta verifies your endpoint with a GET before it will ever send you a POST. The same URL has to answer both.
app.get('/webhook/whatsapp', (req, res) => { const mode = req.query['hub.mode']; const token = req.query['hub.verify_token']; const challenge = req.query['hub.challenge']; // The verify token is a string you invent and paste into the Meta dashboard. // It is not a secret Meta gives you. if (mode === 'subscribe' && token === process.env.WA_VERIFY_TOKEN) { // Must be echoed back as plain text, not JSON, or verification fails. return res.status(200).send(challenge); } return res.sendStatus(403); }); Two traps in six lines. The verify token is something you choose — people hunt the dashboard for it. And the challenge must come back as a bare string: res.json(challenge) wraps it in quotes and the check fails with no useful error.
While you are here, verify the signature on incoming POSTs too. Meta signs the raw body with your app secret in X-Hub-Signature-256, and you need the raw bytes, before any JSON parser touched them:
import crypto from 'node:crypto'; // Mount this BEFORE express.json() for the webhook route. app.post('/webhook/whatsapp', express.raw({ type: 'application/json' }), (req, res) => { const expected = 'sha256=' + crypto .createHmac('sha256', process.env.WA_APP_SECRET) .update(req.body) // Buffer, untouched .digest('hex'); const given = req.get('X-Hub-Signature-256') ?? ''; const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); if (!ok) return res.sendStatus(401); res.sendStatus(200); // ack first, work after handle(JSON.parse(req.body.toString('utf8'))); }); Without this, anyone who learns your webhook URL can post fake inbound messages to it and make your bot answer — or, worse, make it answer at your expense.
2. Acknowledge in under a few seconds, or you get the same message five times
Meta expects a 200 quickly. If your handler waits for an LLM to finish, the request times out, Meta retries, and your bot answers the same question several times — each answer billable.
So: respond 200 immediately, then do the work. A real queue is better, but even setImmediate beats answering inline.
Retries also mean you will receive duplicates anyway, on timeouts, redeliveries and network hiccups. Every inbound payload carries a message id; store the ones you have processed and drop repeats.
const seen = new Set(); // use Redis or a table in production function handle(payload) { const msg = payload.entry?.[0]?.changes?.[0]?.value?.messages?.[0]; if (!msg) return; // status callbacks arrive on the same webhook if (seen.has(msg.id)) return; seen.add(msg.id); answer(msg); } Note if (!msg) return. Delivery receipts — sent, delivered, read, failed — come to the same webhook under value.statuses, not value.messages. Early on, roughly two thirds of the events I logged were status callbacks, and the handler kept crashing on them.
3. The 24-hour customer service window is the rule that shapes your whole design
This is the one that surprises people coming from web chat.
When a customer messages you, a 24-hour customer service window opens. Inside it you may reply with free-form text — anything your bot wants to say. The window restarts every time the customer sends another message.
Outside the window you cannot send free-form text at all. You may only send a pre-approved template. Not "it gets flagged" — the API rejects it.
Consequences worth designing around:
- Check the window at send time, not at queue time. A reply that sat in a queue for 40 minutes may have been valid when queued and invalid when sent. Re-read the customer's last-message timestamp immediately before you call the API.
- A human handover that takes a day is a different message. If your bot escalates to a person and that person replies tomorrow morning, their reply is no longer free-form. It needs a template. Build that path deliberately or your agents will quietly fail to reach customers.
- You cannot start conversations casually. Any outbound-first message — an abandoned-cart nudge, an appointment reminder — is a template, with an approval queue in front of it.
Store the window state with each conversation rather than computing it ad hoc:
// Call this immediately before every send. function canSendFreeForm(conversation) { if (!conversation.lastInboundAt) return false; const ageMs = Date.now() - conversation.lastInboundAt; return ageMs < 24 * 60 * 60 * 1000; } 4. Templates are a product decision, not a formatting detail
A template is a message approved in advance by Meta, with numbered placeholders you fill at send time:
Hi {{1}}, your order {{2}} has shipped. Track it here: {{3}} Three things make them harder than they look.
The category you pick changes the price. Marketing, utility, authentication and service are billed differently, and Meta has been adjusting that model repeatedly — most recently with changes effective 1 October 2026. Do not hardcode the rate card into your business case; read Meta's current pricing page the week you launch, and again before you scale. Anything written about WhatsApp pricing more than a few months ago, including blog posts quoting confident numbers, is likely wrong.
Approval takes time and can be rejected. Promotional language in a utility template gets declined. Build your templates early, not the day before launch.
Placeholders cannot contain newlines or tabs, and a parameter that arrives empty fails the send. Validate before calling the API, because the error you get back is not specific.
The practical pattern: keep a small set of templates whose job is to re-open a conversation — "You asked about X, reply to continue" — and keep all the real intelligence in the free-form replies inside the window. Trying to express a whole AI conversation through approved templates is a dead end.
5. Delivery is not confirmation, and failures arrive later
The 200 from the send endpoint means Meta accepted the message. It says nothing about whether it arrived. Delivery, read and failure all arrive later, on the webhook, as status events.
A message can fail after acceptance because the number is not on WhatsApp, because the user blocked you, or because you hit a rate limit. If your UI shows "sent" based on the POST response, it is showing fiction. Carry the status events through to whatever the business actually looks at.
The part that is genuinely easy
After all of that, sending is anticlimactic:
await fetch( `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_NUMBER_ID}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.WA_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ messaging_product: 'whatsapp', // required, and easy to forget to: customerPhoneE164, // digits only, country code, no '+' type: 'text', text: { body: answer, preview_url: false } }) } ); messaging_product is mandatory on every call and its absence produces an error message that does not mention it.
What I would tell myself at the start
Build the window logic before the bot. The retry and duplicate handling before the prompt. The template set before the launch date. Those four things are where the week went, and none of them are about AI at all — they are the cost of being a guest on someone else's messaging network.
The upside is real, though: WhatsApp is where a lot of customers already are, and a bot that answers there at 11pm is doing work nobody was going to do anyway.
I build ChatNory, which puts one chatbot — trained on a business's own content — on a website, WhatsApp, Messenger and Telegram at the same time. Every bruise above is first-hand. If you have found a cleaner way to handle the handover-after-24-hours problem, I would genuinely like to hear it.


