Skip to content
← Blog01 Oct 2026 · 10 min read

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.

WhatsApp Cloud APIConversational AIFreelance

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.

  1. A Business Portfolio at business.facebook.com, formerly called Business Manager. It owns everything else on this list.
  2. A Meta app at developers.facebook.com, with the WhatsApp product added. Meta gives you a free test number straight away: a +1 555 number that can message up to five phones you allow-list, with an access token that lasts 24 hours. Build and test your bot against this.
  3. A WhatsApp Business Account inside the portfolio. It holds your phone numbers, the billing currency and any message templates. Meta reviews it.
  4. The phone number. Add it to the account and prove you own it with a code sent by SMS or call. Then register it with the API, which is a separate step (see below). The API refers to the number by a phone number ID, which is not the number itself.
  5. A display name, the name customers see. Meta checks it against your brand.
  6. A System User and its token. A System User is a non-human user in the portfolio. Give it access to the app and the account, and create a token for it that never expires, with three permissions: whatsapp_business_messaging, whatsapp_business_management and business_management. Don't go live on the token from the API Setup page. It expires after 24 hours, and when it does the bot stops replying and the customer sees nothing at all.
  7. The webhook. In the app's WhatsApp settings, enter your bot's public URL and a verify token, which is any string you choose. Subscribe to the messages field. Then check that the account is subscribed to the app (see below).
  8. Switch the app to Live. In Development mode, only people with a role on the app can message the number. Live opens it to everyone. Meta asks for a privacy policy URL, an app icon and a category before it lets you switch.

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:

  • the System User token and the app secret
  • the six-digit PIN used to register the number
  • the app ID, the WhatsApp Business Account ID and the phone number ID

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 symptomMeaning
Webhook "couldn't be validated"The verify token doesn't match, or the service is asleep
401 from your own POST /webhookThe app secret doesn't belong to the app sending the webhook
A real message, nothing in the logsThe account isn't subscribed to the app, the app is still in Development mode, or the messages field is off
131030Recipient not in the allowed list: you're still sending from the test number
131047More than 24 hours since the customer's last message
190The token has expired or been revoked
133010The number was added but never registered
133016More than 10 register attempts in 72 hours
131026Undeliverable: no WhatsApp, blocked, or the account is restricted
Newsletter

More like this, occasionally.

Notes on building AI products. No spam, unsubscribe anytime.