WhatsApp AI bot: architecture and costs

A WhatsApp AI bot is a server that receives messages from Meta's Cloud API through a webhook, queries a language model and replies. For a bot that only answers, the model is usually the biggest cost.

Updated 7 min read

Learning objectives

After reading this article you will be able to:

  • Understand the path of a WhatsApp message to the LLM and back
  • Write a FastAPI webhook that verifies the signature and replies with AI
  • Estimate what Meta, the model and hosting charge
On this page

A WhatsApp AI bot looks complicated, but it is an ordinary web API that talks to two parties: Meta, which delivers and sends the messages, and a language model provider, which writes the reply. Your code sits in the middle and decides what the model knows and how much you spend.

The most important decision comes before any code: use Meta's official Cloud API or an unofficial library. Here we use the Cloud API, because it is the supported route and it works through a webhook, without keeping a WhatsApp Web session open.

UserWhatsAppCloud APIofficialmessagereplyPOSTPOSTYour serverHTTPS webhookchecks signaturereturns 200 OKskips duplicatesLLM APIwrites the replyhistorytext
The webhook checks the signature and returns 200 right away; the LLM runs in the background and the reply goes back through the Cloud API.

What does the architecture of a WhatsApp AI bot look like?#

There are four pieces and a round trip:

  1. The user sends a message to the bot's number.
  2. Meta's Cloud API sends a POST to your webhook, a public HTTPS endpoint.
  3. Your server validates the signature, returns 200 OK and, in the background, sends the conversation history to the LLM. To understand what the model does inside, see what an LLM is.
  4. The reply goes back through a POST to the Cloud API, which delivers it to the user.

This has two practical consequences. The server must stay on, because Meta only retries the notification for a limited time. And the webhook needs a public HTTPS URL, so testing on localhost requires a tunnel.

What do you need before you start?#

  • A Meta developer account with an app that has the WhatsApp product added. In the dashboard you get the Phone Number ID, the access token and the app's App Secret.
  • An API key from an LLM provider. The example uses Anthropic's.
  • A Python version that is still supported and a grasp of environment variables.

For the step-by-step on the account and the number, without AI, see how to make a WhatsApp bot.

How do you write the webhook?#

The requirements.txt:

text
fastapi
uvicorn
httpx
anthropic

And main.py. It does four things: answers the verification challenge (GET), checks the X-Hub-Signature-256 signature (POST), skips repeated messages and replies with the model in the background.

python
import hashlib
import hmac
import os
from collections import OrderedDict, defaultdict, deque

import httpx
from anthropic import AsyncAnthropic
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse

VERIFY_TOKEN = os.environ["VERIFY_TOKEN"]  # a string you choose in the Meta dashboard
APP_SECRET = os.environ["APP_SECRET"].encode()
WA_TOKEN = os.environ["WA_TOKEN"]
PHONE_ID = os.environ["WA_PHONE_NUMBER_ID"]
GRAPH_API_VERSION = os.environ["GRAPH_API_VERSION"]  # vXX.0 format, from Meta's changelog
MODEL = os.environ["ANTHROPIC_MODEL"]  # ID from Anthropic's model list
SYSTEM = (
    "You are the customer service assistant of the Example store on WhatsApp. "
    "Reply in English, in at most 3 sentences, with no markdown."
)

SEND_URL = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{PHONE_ID}/messages"
llm = AsyncAnthropic()  # reads ANTHROPIC_API_KEY from the environment
app = FastAPI()

history: dict[str, deque] = defaultdict(lambda: deque(maxlen=10))  # in RAM: lost on restart
seen: OrderedDict[str, None] = OrderedDict()


@app.get("/webhook")
async def verify(
    mode: str = Query("", alias="hub.mode"),
    token: str = Query("", alias="hub.verify_token"),
    challenge: str = Query("", alias="hub.challenge"),
):
    if mode == "subscribe" and hmac.compare_digest(token.encode(), VERIFY_TOKEN.encode()):
        return PlainTextResponse(challenge)
    raise HTTPException(403)


@app.post("/webhook")
async def receive(request: Request, tasks: BackgroundTasks):
    raw = await request.body()  # the signature covers the raw body, not re-parsed JSON
    expected = "sha256=" + hmac.new(APP_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected.encode(), received.encode()):
        raise HTTPException(403)

    for entry in (await request.json()).get("entry", []):
        for change in entry.get("changes", []):
            for msg in change.get("value", {}).get("messages", []):
                if msg["id"] in seen or msg.get("type") != "text":
                    continue
                seen[msg["id"]] = None
                if len(seen) > 1000:
                    seen.popitem(last=False)
                tasks.add_task(answer, msg["from"], msg["text"]["body"])
    return {"ok": True}  # immediate 200; Meta resends if it does not get one


async def answer(user: str, text: str):
    turns = history[user]
    turns.append({"role": "user", "content": text})
    while turns and turns[0]["role"] != "user":
        turns.popleft()  # the history must start with "user"
    try:
        out = await llm.messages.create(
            model=MODEL,
            max_tokens=1024,
            output_config={"effort": "low"},
            system=SYSTEM,
            messages=list(turns),
        )
        reply = "".join(b.text for b in out.content if b.type == "text") or "..."  # skips thinking blocks
    except Exception as e:
        print("llm error:", e)
        reply = "I could not reply right now. Please try again in a moment."
    turns.append({"role": "assistant", "content": reply})

    async with httpx.AsyncClient(timeout=15) as http:
        r = await http.post(
            SEND_URL,
            headers={"Authorization": f"Bearer {WA_TOKEN}"},
            json={
                "messaging_product": "whatsapp",
                "to": user,
                "type": "text",
                "text": {"body": reply},
            },
        )
        if r.status_code >= 400:
            print("whatsapp error:", r.status_code, r.text)

What to notice in the code:

  • Return 200 before calling the model. If the endpoint does not return 200, Meta retries with decreasing frequency for up to 7 days. Without seen, the customer would get duplicate replies.
  • Validate the signature. Meta's documentation calls it optional but recommended. Without it, anyone who finds the URL can send fake messages and burn your AI credit.
  • from is the user's number and works directly as the destination (to) of the reply.
  • The model and the Graph API version are configuration. Anthropic retires models, and each Graph API version stops working about two years after release. So both live in environment variables with no default: if one is missing, the app stops at startup with KeyError, and an update means changing the value and restarting. Copy the ID of a small, fast, low-cost model (from the Haiku line, for example) from Anthropic's model list and the latest version from the Graph API changelog.
  • Reasoning counts toward max_tokens. If the model you picked thinks before replying, those tokens count against the limit and are billed as output. With max_tokens set too low, the reply can come out cut off or empty. A low effort reduces reasoning for simple customer service, and 1024 leaves room for a short reply. If the model does not accept the effort parameter, the API returns a 400 error: remove that line.
  • The code only handles text. Audio, images and buttons arrive with a different type; add a branch for each one your bot supports.

How do you configure and test the webhook?#

Run it locally and expose the port with an HTTPS tunnel:

bash
pip install -r requirements.txt
export VERIFY_TOKEN="a-secret-string" APP_SECRET="..." WA_TOKEN="..." \
       WA_PHONE_NUMBER_ID="..." ANTHROPIC_API_KEY="..." \
       ANTHROPIC_MODEL="id-of-a-small-model" GRAPH_API_VERSION="vXX.0"
uvicorn main:app --port 8000

In the Meta dashboard, under the WhatsApp configuration, enter the URL https://your-domain/webhook and the same VERIFY_TOKEN, and subscribe to the messages field. To see verification working before that:

bash
curl "localhost:8000/webhook?hub.mode=subscribe&hub.verify_token=a-secret-string&hub.challenge=123"
echo "(the response should be 123)"

Then send a message from your phone to the bot's number and watch the server logs.

How much does a WhatsApp AI bot cost?#

There are three separate bills:

  • Meta. Billing is per delivered template, not per conversation. Non-template messages (text, images) inside the 24-hour customer service window are free. A bot that only answers customers generally stays inside that window. Outbound messages, such as notices and promotions, use templates and are charged by category and country. See the details in WhatsApp Business API cost.
  • Model. You pay per input and output token. Since the history is resent with every message, long conversations cost more; the maxlen=10 and max_tokens=1024 in the code keep that in check. To run the numbers, see how much an LLM API costs.
  • Hosting. A webhook that only relays text uses little memory. What matters is staying on all the time.

What are the limits and common errors?#

  • Memory is lost on restart. history lives in RAM. For production, store the last messages in Redis or a database, keyed by phone number.
  • 24-hour window. Outside it, only approved templates go through. If the bot needs to start conversations, plan templates.
  • Saving the webhook in the dashboard fails: the VERIFY_TOKEN in the dashboard differs from the one in the environment variable, or the server is not reachable over public HTTPS.
  • The signature is always invalid: the APP_SECRET is wrong, or you validated against re-serialized JSON instead of request.body().
  • Duplicate replies: the server was slow or failed to return 200. Confirm that the model call runs in the background.
  • Abuse and prompt injection: the user controls the text the model reads. Do not give the model tools or sensitive data without filtering, and for high volume, rate-limit messages per number.

How to do this on Square Cloud#

The webhook needs two things a home computer does not give you: being up whenever Meta calls, and a public HTTPS URL. On Square Cloud the application runs in an isolated, always-on container with no cold start and no sleep mode, and gets a *.squareweb.app subdomain with HTTPS that can serve as the webhook URL in the Meta dashboard, with no tunnel.

  1. Upload main.py and requirements.txt as a ZIP in the dashboard, or use the CLI, GitHub or the VS Code extension. The squarecloud.app file is optional: the dashboard generates it at deploy time, or you write it at the project root.
  2. Use uvicorn main:app --host 0.0.0.0 --port $PORT as the start command: a web application listens on 0.0.0.0, on the port in the PORT variable, and uses at least 512 MB from the plan's RAM pool.
  3. Set VERIFY_TOKEN, APP_SECRET, WA_TOKEN, WA_PHONE_NUMBER_ID, GRAPH_API_VERSION, ANTHROPIC_API_KEY and ANTHROPIC_MODEL in the application's environment variables. The change applies on the next restart, with no rebuild.
  4. Send the first message with the real-time logs open: errors from Meta and from the model show up there.

From the Standard plan (R$44.99 per month), Redis hosting can keep the history across restarts, and on the same plan the AI Gateway can replace the model provider: an OpenAI-compatible API for the cubic model, in beta with early access at no extra cost. It accepts text only, which is all this webhook handles. Add openai to requirements.txt, swap the call inside answer and delete the MODEL line, which is no longer used:

python
from openai import AsyncOpenAI

llm = AsyncOpenAI(
    base_url="https://api.squarecloud.app/v2/ai",
    api_key=os.environ["SQUARECLOUD_API_KEY"],  # account key with the ai:chat scope
)

# inside answer():
out = await llm.chat.completions.create(
    model="cubic",
    messages=[{"role": "system", "content": SYSTEM}, *turns],
)
reply = out.choices[0].message.content or "..."

Check the limits before you switch: on Standard, up to 2.5 million tokens and up to 1,000 requests per day, one request at a time. When many customers write at once, a call that arrives while another is in flight gets a 429 error; the OpenAI SDK retries a few times, and the code's except sends the fallback reply. Limits and plans may change when the beta ends. Hobby, starting at R$12.49 per month, hosts the webhook but includes neither databases nor the gateway, and Meta bills templates separately. See the details on WhatsApp bots.

AI

Host AI apps in always-on containers

Learn more

FAQs

Can I use a library like Baileys instead of the Cloud API?

You can, but libraries like Baileys are not Meta's official route and depend on an open WhatsApp Web session. The Cloud API is the supported option and works through a webhook, with no session to maintain.

Why does the bot reply twice to the same message?

Meta resends the notification if your server does not return 200. Return 200 right away, do the work in the background and store the IDs of messages you already handled.

Can the bot reply at any time?

Free-form messages only go out while the 24-hour customer service window is open, and the window restarts with each customer message. Outside it, only templates approved by Meta.

Does Meta charge for every reply the bot sends?

No. Non-template messages sent inside an open customer service window are free. Meta charges per delivered template, based on its category and the country.

Next articleWhat is a vector database?A vector database stores embeddings (lists of numbers) and finds the items closest in meaning, not just in keywords. Learn how it works and when to use one.