Putting your bot on a real WhatsApp number: a Cloud API guide
A practical guide to putting your own bot on a real WhatsApp number through Meta's Cloud API: what to set up in Meta and in what order, how to connect your code, and the mistakes worth avoiding. Written after taking a client's bot live in September.
In September I took a WhatsApp sales and support bot live for Divo, an Indian car dashcam brand. I expected writing the bot to be the hard part. Getting it onto a real number that anyone can message took more planning, and almost none of that was code. It was Meta's business setup.
This is the guide I wanted before I started. It assumes you already have a bot that can turn a message into a reply, and covers the rest: what to set up in Meta and in what order, how to connect your code to Meta's WhatsApp Cloud API, and the mistakes worth avoiding. Meta changes its screens and terms often, so check its pages as you go.
Two decisions to make first
Go direct, or through a provider. You can connect to Meta's Cloud API yourself, or go through a Business Solution Provider (BSP) such as Wati, Interakt or AiSensy, which resell WhatsApp access with a shared inbox for your team. Going direct means no monthly fee and full control of your code. It also means no inbox, so your team can't see or answer the bot's chats. I went direct, and everything below assumes you do too.
Pick the number. A number can be in the WhatsApp Business app or on the Cloud API, not both. Meta does offer both on one number, which it calls coexistence, but only through its partners. Moving a number to the API deletes its app account and its chat history. So either give the bot a new number, or move your main number to the bot and give the human team a new one. Divo did the second, because customers already message the main number.
What to set up in Meta
Do these in order. If you're building for a client, every piece should belong to the client's business, not to you. Otherwise the client doesn't own its own number.
Three things you probably don't need. App Review is only for apps that work with other businesses' accounts. An app that sends only from its own business's account is covered by standard access. Business Verification, Meta's check of a business's legal name, address and documents, is not needed to go live. It unlocks growth: higher messaging limits, more numbers and the green tick. Those limits only cap conversations the business starts, so they don't hold back a bot that only replies. And templates are only needed if your bot messages people first.
If you're building for a client
Almost every step above happens inside the client's Meta account, so the client's admin should do them. You never need their login. When they're done, the admin sends you:
You send the admin your webhook URL, the verify token and a privacy policy URL, then tell them when the bot is ready for Live. Send secrets through a password manager or a one-time link, never over WhatsApp or email.
Connecting your bot
Your bot needs one public URL with two routes: a GET for Meta's one-time handshake, and a POST that receives every message. Here is a minimal version in Node.js with Express. It isn't the Divo bot's code, just the parts Meta requires.
import crypto from "node:crypto";
import express from "express";
const app = express();
// Keep the raw body: the signature is computed over the exact bytes Meta sent.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
// The handshake, once, when you save the webhook in the app's settings.
app.get("/webhook", (req, res) => {
const ok = req.query["hub.mode"] === "subscribe"
&& req.query["hub.verify_token"] === process.env.VERIFY_TOKEN;
if (!ok) return res.sendStatus(403);
res.status(200).send(req.query["hub.challenge"]);
});
// Every incoming message after that.
app.post("/webhook", (req, res) => {
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.APP_SECRET)
.update(req.rawBody)
.digest("hex");
const got = req.get("X-Hub-Signature-256") ?? "";
const valid = got.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
res.sendStatus(200); // answer Meta first
handle(req.body).catch(console.error); // then do the slow part
});
async function handle(body) {
// Meta can batch several messages into one POST, so loop over all of them.
// Status updates arrive under value.statuses, so they never reach answer().
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
for (const msg of change.value?.messages ?? []) {
if (msg.type === "text") await answer(msg);
}
}
}
}
async function answer(msg) {
const reply = await yourBot(msg.text.body);
await fetch(`https://graph.facebook.com/v26.0/${process.env.PHONE_NUMBER_ID}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHATSAPP_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: msg.from,
type: "text",
text: { body: reply },
}),
signal: AbortSignal.timeout(10_000),
});
}
app.listen(process.env.PORT ?? 3000);
What each part is for:
The handshake. When you save the webhook, Meta sends a GET request with your verify token and a random challenge. If the token matches, you send the challenge back. That's all it is.
The signature. Every POST carries an X-Hub-Signature-256 header, a hash of the request body made with your app secret. Check it and reject anything that doesn't match, or anyone who finds your URL can feed your bot messages. Hash the raw body, not JSON you've parsed and turned back into text, or the hashes won't match. It's also worth making the bot refuse to start without the secret, so it can't run unprotected by accident.
Answer first, work after. Meta waits only a few seconds for its 200. If it doesn't get one, it sends the same message again, with growing gaps, for up to seven days. A model call can take longer than Meta waits, so reply 200 straight away and write the answer afterwards. Repeats still arrive now and then, so remember the message IDs you've handled and skip any you've seen.
Ignore what isn't a message. For every reply you send, Meta posts updates to the same URL as it is sent, delivered and read. They come under statuses, not messages. Drop them.
Sending is one POST to the /messages endpoint with your phone number ID and token. Pin the API version in the URL so Meta's updates don't change behaviour under you. The same endpoint can also mark the customer's message as read and show "typing…", which helps because a model reply takes a few seconds. WhatsApp clears the typing indicator after 25 seconds, so refresh it if your model is slow.
Hosting. The URL has to be public HTTPS and always awake. A free tier that sleeps won't do. Render's free tier sleeps after about 15 minutes idle and can take up to a minute to wake, which is far longer than Meta waits. And if you keep conversations or the list of handled message IDs in memory, run a single instance, or two copies will answer the same message twice. I run the bot as a single paid web service on Render.
Mistakes to avoid
Adding a number is not registering it. Proving you own the number adds it to the account. To connect it to the API, you also POST to /{phone number ID}/register with messaging_product: "whatsapp" and a six-digit PIN. If the number had no two-step verification, that PIN becomes its two-step PIN, so keep it. You get 10 attempts in 72 hours. After that you get error 133016 and have to wait.
A webhook that works for the test number can get nothing from the real one. The webhook URL is set on the app, but each WhatsApp Business Account has to be subscribed to the app on its own. Check with GET /{WABA ID}/subscribed_apps, and subscribe with a POST to the same address.
The dashboard's Test button doesn't prove delivery. It posts a sample message straight to your URL and skips the subscription. Only a real message from a real phone proves it works.
Mind the 24-hour window. You can send free-form replies only within 24 hours of the customer's last message. After that, only pre-approved templates work, and a free-form message fails with error 131047. For a bot that only replies, this matters only if it's down for more than a day.
Make the bot's hand-over match your setup. This one caught me. While testing, my bot told customers who needed a person that the team read the same chat and would pick it up. Once the number moved to the API with no inbox, nobody could. For about a day after the move, a customer could have waited for a reply that was never coming. I changed every hand-over to give the support line, email and hours, and told the bot plainly that nobody from the team reads its chat. Whenever your setup changes, reread everything your bot says about what happens next.
What it costs on Meta's side
Meta charges per message. Since November 2024, replies inside the 24-hour window have been free, but Meta's non-template pricing page says that from 1 October 2026 it charges for these service messages too, at the same per-market rate as utility messages. Its main pricing page still describes them as free, so read both before you plan a budget.
If your account is billed in India, it must move to rupee billing by 31 December 2026. From 1 January 2027, Meta stops delivering messages for accounts that aren't billed in rupees.
The other cost is the model. For this bot, a reply costs about $0.0004 when the prompt is cached. The case study has the measurements.
Error codes worth knowing
| Code or symptom | Meaning |
|---|---|
| Webhook "couldn't be validated" | The verify token doesn't match, or the service is asleep |
401 from your own POST /webhook | The app secret doesn't belong to the app sending the webhook |
| A real message, nothing in the logs | The account isn't subscribed to the app, the app is still in Development mode, or the messages field is off |
131030 | Recipient not in the allowed list: you're still sending from the test number |
131047 | More than 24 hours since the customer's last message |
190 | The token has expired or been revoked |
133010 | The number was added but never registered |
133016 | More than 10 register attempts in 72 hours |
131026 | Undeliverable: no WhatsApp, blocked, or the account is restricted |
More like this, occasionally.