Nicolás Duque

WhatsApp chatbots, part 1: the setup nobody documents well

Sep 22, 2026 · 10 min read

Every business I talk to in Colombia wants the same thing: a WhatsApp number that answers on its own. Not email, not a web chat widget, not an app. WhatsApp.

The bot itself is the easy part. The part that eats a day is everything before it: five nested objects in Meta's account model, a test number that can only talk to five people, a token that dies in 24 hours, and a pricing page that changed shape in 2025. This series builds a real WhatsApp chatbot end to end, and this post is the boring, necessary first half-day.

The plan:

  1. Setup: the account model, the app, the test number, your first message and your first webhook (this post).
  2. The webhook server: Node/NestJS, signature verification, idempotency, and why you must answer in under five seconds.
  3. Conversation state: the 24-hour window, templates, and handing off to a human.
  4. Plugging in a model: tools, guardrails, and keeping the token bill sane.
  5. Going live: a real number, business verification, quality rating and monitoring.

Everything here comes from walking the flow myself in September 2026. Meta moves this console around; if a button moved, the object model below still holds.

Three different products share the name "WhatsApp Business"

Before touching anything, know which one you want:

  • The WhatsApp Business app: the green app your local bakery installs on a phone. Free, no API, quick replies and labels. A human has to type.
  • The WhatsApp Business Platform, Cloud API: Meta hosts it, you call a REST endpoint. This is what the series uses.
  • The On-Premises API: the old self-hosted Docker stack. Meta has been retiring it in favour of the Cloud API. Don't start a new project on it.

You can also go through a BSP (Twilio, 360dialog, Infobip). They wrap the same API with a nicer console, a sandbox that works in five minutes, and a margin on every message. Going direct is cheaper and, once you've read this post, not harder.

The five objects, and how they nest

This is the part that confuses everyone, because Meta's UI shows you the leaves and never the tree:

Meta (Facebook) account          ← you, a human
└── Business portfolio           ← the company; owns assets, gets verified
    ├── Meta app                 ← your code's identity; holds the WhatsApp product
    └── WhatsApp Business Account (WABA)
        └── Business phone number  ← the number customers write to

Two more things are not objects in that tree but you can't send a message without them: an access token (proves the app may use the WABA) and a webhook (an HTTPS URL of yours that Meta posts to when someone writes).

Names you'll see in the API: WABA_ID, PHONE_NUMBER_ID. The phone number ID is not the phone number — it's an ID for the number inside the WABA, and it's what goes in the URL.

Step 1: create the Meta app

Go to developers.facebook.comMy AppsCreate app. The name is internal: no customer ever sees it.

Meta app creation wizard, App details step, with the app name wa-tutorial-bot filled in and the contact email blacked out
Step 1 of 5. The app name only shows up in your own dashboard.

Next comes the use case. Pick Connect with customers through WhatsApp — this is what adds the WhatsApp product to the app. Note the line in the card: "Business portfolio required."

Use case selection screen with 'Connect with customers through WhatsApp' checked, and a warning that some use cases can't be combined in the same app
One app, one job. Meta warns you that some use cases can't be combined.

Then the wizard asks which business portfolio owns the app.

Business portfolio selection step of the Meta app wizard, with one portfolio selected and the other two blacked out, and a 'Create a business portfolio' link below
Pick the portfolio that owns the app. The link at the bottom creates one — up to a limit.

Two traps here, both of which I hit:

  • There's a cap on business portfolios per person. Try to create one too many and you get "Maximum number of businesses reached", with no number and no way to raise it in the UI. If you already manage a few businesses, plan to reuse one.
  • Meta asks for your Facebook password again at the last step, after the review screen. Have it ready; the wizard keeps your answers if you fail it.

The portfolio does not have to be verified yet. Unverified is fine for the test number and for development; verification becomes a requirement when you want a real number with real limits, which is part 5.

Step 2: the test number you get for free

Older tutorials tell you to look for a WhatsApp entry in the app's left menu with an API Setup page under it. That menu is gone. In the current console you go to Use cases → Customize, accept the WhatsApp Business and Cloud API hosting terms once, and you land in a guided flow with three steps: Try it out, Production setup, Business verification.

It opens with the same tree I drew above, which is a good sign that the tree is the thing to understand:

Meta's guided WhatsApp setup overview: integration type, three steps (try it out, production setup, business verification), and an assets tree listing Facebook account, Meta app, webhooks, business portfolio, test WABA, your WABA, phone number, message templates, payment method, system user and business verification
Meta's own map of what you'll end up owning. Step 1 gives you the test WABA; everything else is step 2 and 3.

Step 1. Try it out claims a test number for you automatically. At no cost you get:

  • a test business phone number (a US number, owned by Meta, not transferable),
  • its Phone Number ID and the WhatsApp Business Account ID,
  • an access token you create with Generate token,
  • a recipient picker where you can add up to five phone numbers, each verified by a code,
  • a prefilled curl with a sample template, a Run in Postman button, and a live Check test webhooks panel.
Step 1 Try it out panel: test number +1 555 164-8389 with the Phone Number ID and WhatsApp Business account ID blacked out, a generated access token blacked out, a recipient picker, a message template selector and a prefilled curl command
The test panel, with the IDs and the token blacked out. The token is real for 24 hours, so treat that field like a password.

That's the sandbox. It's genuinely useful — you can build the whole bot against it — but be clear about what it isn't: you cannot promote the test number to your business number later, and you cannot message anyone outside those five verified numbers.

The token from that panel is a 24-hour token. Don't wire it into anything you'll still be running tomorrow, and don't commit it. The permanent one comes from a system user; see step 6.

Step 3: your first message, from the terminal

The whole API is one endpoint shape:

curl -X POST \
  "https://graph.facebook.com/v25.0/$PHONE_NUMBER_ID/messages" \
  -H "Authorization: Bearer $WHATSAPP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "to": "573001112233",
    "type": "template",
    "template": {
      "name": "jaspers_market_order_confirmation_v1",
      "language": { "code": "en_US" }
    }
  }'

Three details that cost people an afternoon:

  • The version: the console prefilled v25.0 for me today. Copy whatever it shows and pin it. Graph API versions are supported for about two years and then start failing.
  • to: country code, no +, no spaces, no dashes. 573001112233, not +57 300 111 2233.
  • That name is a template, and that's deliberate. To a person who has never written to you, you can only send a pre-approved template. Free-form text is rejected. New test accounts come with sample templates preloaded — mine had an "Order Confirmation" one called jaspers_market_order_confirmation_v1; older tutorials use hello_world. Use the dropdown in the console to see what your account actually has.

A success looks like this, and the wamid is the message ID you'll match against later in webhooks:

{
  "messaging_product": "whatsapp",
  "contacts": [{ "input": "573001112233", "wa_id": "573001112233" }],
  "messages": [{ "id": "wamid.HBgMNTczMDAxMTEyMjMzFQIAERgS..." }]
}

Once that person replies, you have 24 hours in which plain text works:

curl -X POST \
  "https://graph.facebook.com/v25.0/$PHONE_NUMBER_ID/messages" \
  -H "Authorization: Bearer $WHATSAPP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "recipient_type": "individual",
    "to": "573001112233",
    "type": "text",
    "text": { "body": "Hello from the Cloud API" }
  }'

Step 4: receiving messages

Sending is a REST call. Receiving is a webhook, and Meta needs a public HTTPS URL with a valid certificate. In development that means a tunnel:

npx localtunnel --port 3000
# or
cloudflared tunnel --url http://localhost:3000
# or
ngrok http 3000

Your endpoint has to answer two different kinds of request. The first is the one-time verification handshake: a GET with hub.mode, hub.verify_token and hub.challenge. The verify token is a string you invent and paste into both the console and your code. Echo the challenge back as plain text:

import express from "express";

const app = express();
app.use(express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }));

app.get("/webhook", (req, res) => {
  const mode = req.query["hub.mode"];
  const token = req.query["hub.verify_token"];
  const challenge = req.query["hub.challenge"];

  if (mode === "subscribe" && token === process.env.VERIFY_TOKEN) {
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});

The second is the real traffic: a POST for every inbound message and every status change. Answer 200 immediately — Meta retries anything slow or failed, and retries turn one customer message into several.

app.post("/webhook", (req, res) => {
  res.sendStatus(200); // first, always

  const value = req.body.entry?.[0]?.changes?.[0]?.value;
  const message = value?.messages?.[0];
  if (!message) return; // delivery status, not a message

  console.log("from", message.from, "text", message.text?.body);
});

app.listen(3000);

The payload is nested deeper than you'd expect, and the array shape is not decoration — a single notification can carry more than one message:

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "<WABA_ID>",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "15550001111", "phone_number_id": "<PHONE_NUMBER_ID>" },
        "contacts": [{ "profile": { "name": "Nicolás" }, "wa_id": "573001112233" }],
        "messages": [{
          "from": "573001112233",
          "id": "wamid.HBgMNTczMDAxMTEyMjMzFQIAERgS...",
          "timestamp": "1790022604",
          "type": "text",
          "text": { "body": "hola" }
        }]
      }
    }]
  }]
}

Back in the console the webhook form lives in Step 2. Production setup → Configure Webhooks: paste the callback URL, paste the same verify token, and hit Verify and save. Then subscribe to the messages field — saving the URL is not enough, and if you skip the subscription the handshake passes while no message ever arrives.

Step 2 Production setup screen: Configure Webhooks with callback URL and verify token fields, a warning that unpublished apps only receive test webhooks, and collapsed sections for registering a phone number, adding payment and sending a message
The webhook form, and the warning worth reading twice.

That orange banner is the second hour-killer: "Apps will only be able to receive test webhooks sent from the app dashboard while the app is unpublished. No production data, including from app admins, developers or testers, will be delivered unless the app has been published." So while you develop, the messages you see are the ones the console's own test panel generates. Real inbound messages from a real phone need a published app, which is part of going live.

Step 5: read the pricing page before you design the bot

Meta moved to per-message pricing on 1 July 2025; the old per-conversation model is deprecated. Messages fall into four categories: marketing, utility, authentication and service. What matters for a chatbot:

  • Non-template messages sent inside an open customer service window are free.
  • Utility templates delivered inside that window are free too.
  • Anything that opens a conversation from your side — marketing, authentication, a utility template outside the window — is billed per message, and prices vary by country.
  • Conversations that start from a click-to-WhatsApp ad or a Page call-to-action get a 72-hour free entry point window.

And the rule that shapes the whole design: the customer service window is 24 hours from the customer's last message. Inside it, your bot says whatever it wants. Outside it, you are limited to approved templates, and template approval takes time and rejects marketing dressed up as utility.

So a WhatsApp bot is reactive by nature. If your product plan says "it will follow up three days later", that follow-up is a paid, pre-approved template, not a chat message. Better to find that out now than in the demo.

Step 6: what changes when you go to a real number

Briefly, because this is part 5's job:

  • A permanent token. Business settings → System users → create one → Assign assets (the app and the WABA, full control) → Generate token with whatsapp_business_messaging, whatsapp_business_management and business_management. Store it as a secret; it doesn't expire, which cuts both ways.
  • A number that isn't in use. The number you register must not be active on the WhatsApp or WhatsApp Business app, or you have to migrate it and lose the chat history on that device.
  • A display name that gets reviewed against Meta's naming policy, and a two-step verification PIN you will absolutely forget.
  • Business verification and messaging tiers: you start capped at a low number of unique customers per day and move up based on volume and quality rating.

Where this leaves you

If you followed along you now have: an app, a test number, a token that expires tomorrow, a message you sent from a terminal, and a tunnel that prints inbound messages to your console. That's the whole platform, and it's enough to build against.

In part 2 I'll throw away the express snippet above and build the webhook service properly: payload signature verification with the app secret, idempotency on wamid (because retries will hit you), a queue so the HTTP handler stays under Meta's patience, and the message-status stream that tells you what actually got delivered.