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
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.
What does the architecture of a WhatsApp AI bot look like?#
There are four pieces and a round trip:
- The user sends a message to the bot's number.
- Meta's Cloud API sends a
POSTto your webhook, a public HTTPS endpoint. - Your server validates the signature, returns
200 OKand, in the background, sends the conversation history to the LLM. To understand what the model does inside, see what an LLM is. - The reply goes back through a
POSTto 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'sApp 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:
fastapi
uvicorn
httpx
anthropicAnd 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.
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. Withoutseen, 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.
fromis 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. Withmax_tokensset too low, the reply can come out cut off or empty. A loweffortreduces reasoning for simple customer service, and 1024 leaves room for a short reply. If the model does not accept theeffortparameter, 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:
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 8000In 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:
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=10andmax_tokens=1024in 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.
historylives 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_TOKENin 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_SECRETis wrong, or you validated against re-serialized JSON instead ofrequest.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.
- Upload
main.pyandrequirements.txtas a ZIP in the dashboard, or use the CLI, GitHub or the VS Code extension. Thesquarecloud.appfile is optional: the dashboard generates it at deploy time, or you write it at the project root. - Use
uvicorn main:app --host 0.0.0.0 --port $PORTas the start command: a web application listens on0.0.0.0, on the port in thePORTvariable, and uses at least 512 MB from the plan's RAM pool. - Set
VERIFY_TOKEN,APP_SECRET,WA_TOKEN,WA_PHONE_NUMBER_ID,GRAPH_API_VERSION,ANTHROPIC_API_KEYandANTHROPIC_MODELin the application's environment variables. The change applies on the next restart, with no rebuild. - 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:
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
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.
More in AI
See all 13 articles- How much does an AI chatbot cost to build and run?Running an AI chatbot costs model tokens, hosting, a database and, with RAG, extra context per message. See how to estimate the cost per conversation.
- How much does a RAG system cost? Embeddings, database and LLMA RAG system has three costs: embeddings, a vector database and the LLM. See the math for 1,000 questions a day, with hypothetical prices, and where the bill grows.
- How to build a Discord AI botBuild a Discord AI bot with discord.js: an /ask command, the API key in an environment variable, per-channel memory, Discord limits and how to host it.
- How to host an AI chatbotAn AI chatbot is an API that takes messages, calls a language model and streams the reply back. Here is the code and how to put it online.