← Blog

·

How to Build a WhatsApp Bot in Python with FastAPI (Official Cloud API, from Scratch)

Updated on October 28, 2026

To build a WhatsApp bot in Python on the official API, you need an HTTPS server with two routes: a GET that answers Meta’s verification challenge and a POST that receives messages, validates the X-Hub-Signature-256 signature and replies by calling the Cloud API /messages endpoint. With FastAPI and httpx, that fits in a single file of about 150 lines, and this tutorial builds it from scratch.

The finished bot answers “hi” or “menu” with three buttons, handles each button tap, marks messages as read, ignores duplicate events and runs in production with Docker and automatic HTTPS. The code was tested with FastAPI 0.128 and works both directly with Meta and through Vectalk by changing two environment variables.

How the bot works

The flow has four steps:

  1. A customer sends a message to your WhatsApp Business number.
  2. Meta sends a POST with the event to your webhook URL.
  3. Your server validates the signature, returns 200 right away and processes the event in the background.
  4. The bot calls POST /<PHONE_NUMBER_ID>/messages to reply.

Because the reply goes out right after the customer’s message, it falls inside the 24-hour customer service window, so it can be free-form text or an interactive message with no template. If you are still deciding how to access the API, the WhatsApp Cloud API guide covers the options.

What you need first

VariableWhat it isWhere to find it
PHONE_NUMBER_IDThe number’s ID on the Cloud APIApp Dashboard, WhatsApp section
ACCESS_TOKENToken with the whatsapp_business_messaging permissionSystem user token
APP_SECRETApp secret, used for the signatureApp settings > Basic
VERIFY_TOKENAny string you chooseYou set it and repeat it in the dashboard

Use a permanent system user access token: the temporary token from the dashboard expires and takes the bot down with it. The number must also be registered on the Cloud API; if it isn’t yet, see how to register the number.

Locally, you need Python 3.10 or newer.

Want to try it while you read? Create a Vectalk account and send your first message through the official API. Create free account →

1. Set up the project

mkdir whatsapp-bot && cd whatsapp-bot
python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" httpx

fastapi[standard] installs Uvicorn and the fastapi command. httpx makes the async calls to the API.

Create main.py. The code blocks in sections 1 to 5 make up the complete file: paste them in order.

import hashlib
import hmac
import json
import logging
import os

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

VERIFY_TOKEN = os.environ["VERIFY_TOKEN"]
ACCESS_TOKEN = os.environ["ACCESS_TOKEN"]
APP_SECRET = os.environ.get("APP_SECRET", "")
WEBHOOK_KEY = os.environ.get("WEBHOOK_KEY", "")
PHONE_NUMBER_ID = os.environ.get("PHONE_NUMBER_ID", "")
API_URL = os.environ.get(
    "API_URL", f"https://graph.facebook.com/v26.0/{PHONE_NUMBER_ID}/messages"
)

log = logging.getLogger("bot")
app = FastAPI()
client = httpx.AsyncClient(timeout=10)
processed = set()  # in production, use Redis or your database

API_URL defaults to Meta’s Graph API. To use Vectalk, you only change this variable and the token, as shown in the Vectalk section below.

2. Webhook verification (GET)

When you save the callback URL in the App Dashboard, Meta sends a GET with hub.mode=subscribe, hub.verify_token and hub.challenge. If the token matches yours, return 200 with the hub.challenge value as plain text. Any other response leaves the endpoint unverified, and Meta won’t send events.

@app.get("/webhook")
def verify(request: Request):
    q = request.query_params
    if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == VERIFY_TOKEN:
        return PlainTextResponse(q.get("hub.challenge", ""))
    raise HTTPException(status_code=403)

The parameter names contain a dot (hub.mode), which is why the code reads request.query_params instead of declaring function parameters.

3. Validate the X-Hub-Signature-256 header

Every POST from Meta carries the header X-Hub-Signature-256: sha256=<hash>. The hash is an HMAC-SHA256 of the request body, keyed with your app secret. Without this check, anyone who finds your URL can forge customer messages.

def signature_ok(body: bytes, header: str) -> bool:
    if not APP_SECRET:
        return True
    expected = "sha256=" + hmac.new(APP_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Two details that save hours of debugging:

  • Hash the original bytes. Compute the HMAC over await request.body(), never over re-serialized JSON. A single extra space changes the hash.
  • Compare in constant time. hmac.compare_digest keeps comparison timing from leaking the hash.

The article on validating the webhook signature goes deeper and lists the usual mistakes.

4. Receive the event and acknowledge fast

Meta expects a quick 200. If delivery fails, it retries with decreasing frequency for up to 7 days, so the same event can arrive more than once. That is why the code acknowledges before processing and skips message IDs it has already seen.

@app.post("/webhook")
async def receive(request: Request, background: BackgroundTasks):
    if WEBHOOK_KEY and request.query_params.get("key") != WEBHOOK_KEY:
        raise HTTPException(status_code=403)
    body = await request.body()
    if not signature_ok(body, request.headers.get("x-hub-signature-256", "")):
        raise HTTPException(status_code=401, detail="invalid signature")
    background.add_task(process, json.loads(body))
    return {"ok": True}


async def process(payload: dict) -> None:
    for entry in payload.get("entry", []):
        for change in entry.get("changes", []):
            for msg in change.get("value", {}).get("messages", []):
                if msg["id"] in processed:
                    continue  # Meta may redeliver the same event
                processed.add(msg["id"])
                try:
                    await handle(msg)
                except httpx.HTTPError:
                    log.exception("failed to reply to %s", msg["id"])

FastAPI’s BackgroundTasks runs process after the response is sent. Status events (sent, delivered, read, failed) arrive on the same webhook under value.statuses, and this bot ignores them. To handle them, see WhatsApp API message status webhooks.

5. Reply with text and interactive buttons

The bot logic lives in handle. “hi” or “menu” opens the button menu; a button tap arrives as type: "interactive" with interactive.type: "button_reply" and the id you set when sending.

ANSWERS = {
    "hours": "We're open Monday to Friday, 9 a.m. to 6 p.m.",
    "pricing": "See the full price list at https://example.com/pricing",
    "human": "Sure! An agent will pick up the conversation here.",
}


async def handle(msg: dict) -> None:
    dest = recipient_of(msg)
    await mark_read(msg["id"])
    kind = msg.get("type")
    if kind == "text":
        text = msg["text"]["body"].strip().lower()
        if text in {"hi", "hello", "menu"}:
            await send_menu(dest)
        else:
            await send_text(dest, "Sorry, I didn't get that. Type *menu* to see the options.")
    elif kind == "interactive" and msg["interactive"]["type"] == "button_reply":
        choice = msg["interactive"]["button_reply"]["id"]
        await send_text(dest, ANSWERS.get(choice, "Invalid option."))
    else:
        await send_text(dest, "For now I only understand text and buttons.")


def recipient_of(msg: dict) -> dict:
    # Users with a username may arrive without "from", only with their BSUID
    if "from" in msg:
        return {"to": msg["from"]}
    return {"recipient": msg["from_user_id"]}

recipient_of gets the bot ready for WhatsApp usernames. According to Meta’s documentation, when a user adopts a username the from field may be missing from the webhook, but the business-scoped user ID in from_user_id (the BSUID) is always there, and since July 2026 you can send messages to it with the recipient field. The article on usernames and BSUIDs explains the change.

Finally, the functions that call the API:

async def call_api(payload: dict) -> dict:
    r = await client.post(
        API_URL,
        headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
        json={"messaging_product": "whatsapp", **payload},
    )
    if r.is_error:
        log.error("API %s: %s", r.status_code, r.text)
    r.raise_for_status()
    return r.json()


async def mark_read(message_id: str) -> None:
    try:
        await call_api({
            "status": "read",
            "message_id": message_id,
            "typing_indicator": {"type": "text"},
        })
    except httpx.HTTPError:
        pass  # never blocks the reply


async def send_text(dest: dict, text: str) -> dict:
    return await call_api({
        "recipient_type": "individual",
        **dest,
        "type": "text",
        "text": {"body": text},
    })


async def send_menu(dest: dict) -> dict:
    buttons = [("hours", "Opening hours"), ("pricing", "Pricing"), ("human", "Talk to an agent")]
    return await call_api({
        "recipient_type": "individual",
        **dest,
        "type": "interactive",
        "interactive": {
            "type": "button",
            "body": {"text": "Hi! How can I help?"},
            "action": {
                "buttons": [
                    {"type": "reply", "reply": {"id": i, "title": t}} for i, t in buttons
                ]
            },
        },
    })

mark_read marks the message as read and shows a typing indicator to the customer. Meta removes the indicator when you reply or after 25 seconds.

Reply button limits, according to Meta’s documentation:

FieldLimit
Buttons per messageup to 3
Button title20 characters
Button id256 characters
body.text1,024 characters
footer.text60 characters

6. Test locally

Export the variables and start the dev server with auto-reload:

export VERIFY_TOKEN="any-string-you-like"
export ACCESS_TOKEN="EAAG..."
export APP_SECRET="your-app-secret"
export PHONE_NUMBER_ID="123456789012345"
fastapi dev main.py

Test verification and a signed event without involving Meta:

curl "http://127.0.0.1:8000/webhook?hub.mode=subscribe&hub.verify_token=any-string-you-like&hub.challenge=42"

BODY='{"entry":[{"changes":[{"value":{"messages":[{"from":"15551234567","id":"wamid.test1","type":"text","text":{"body":"menu"}}]}}]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
curl -X POST http://127.0.0.1:8000/webhook \
  -H "Content-Type: application/json" \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  --data-binary "$BODY"

The first command should print 42. The second returns {"ok":true} and makes the bot send the menu to the number in from (use your own test number).

To receive real events, Meta requires a public URL with a valid TLS certificate. During development, a tunnel does the job: testing the WhatsApp webhook locally with ngrok walks through it. With the public URL ready, go to App Dashboard > WhatsApp > Configuration, fill in the callback URL (https://.../webhook) and the verify token, save and subscribe to the messages field.

7. Simple deployment with Docker and HTTPS

Create requirements.txt:

fastapi[standard]
httpx

And the Dockerfile:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["fastapi", "run", "main.py", "--port", "8000"]

fastapi run is production mode, without auto-reload. On a server with Docker:

docker build -t whatsapp-bot .
docker run -d --restart unless-stopped -p 127.0.0.1:8000:8000 \
  -e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \
  whatsapp-bot

HTTPS is the missing piece. A simple option is Caddy, which obtains and renews the certificate on its own when the domain’s DNS points to the server and ports 80 and 443 are open. The whole Caddyfile is two lines:

bot.yourcompany.com
reverse_proxy 127.0.0.1:8000

Update the callback URL in the dashboard to https://bot.yourcompany.com/webhook.

Production checklist

  • Persistent deduplication. The in-memory set disappears on every restart and isn’t shared across processes. Store processed IDs in Redis or your database with an expiry.
  • A queue. If replies depend on an LLM, a database or slow systems, swap BackgroundTasks for a queue (Redis, RabbitMQ, SQS) with a separate worker.
  • One process for now. With in-memory state, run a single worker. Move state out of the process before scaling.
  • Log API errors. The code logs Meta’s error body. The WhatsApp API error codes reference helps you read them.
  • Secrets out of the code. Keep the token and app secret in environment variables or a secrets manager.
  • Media. Images, audio and documents arrive on the webhook with an ID only; downloading them is a separate call to the media API.

Doing it with Vectalk

Vectalk is a proxy for Meta’s official WhatsApp Cloud API: send payloads and webhook events are the same as Meta’s, so main.py works without logic changes. Three things change:

  1. URL and token. Set API_URL=https://apis.vectalk.com.br/api/messages and use a Vectalk instance token as ACCESS_TOKEN. With it, the phone_number_id is resolved automatically.
  2. Webhook registration via API. You register the URL with POST https://apis.vectalk.com.br/api/webhooks, passing instance_id, url and name, and you can have several URLs per instance.
  3. Endpoint protection. Vectalk’s webhook documentation doesn’t describe a signature header. Leave APP_SECRET empty, set WEBHOOK_KEY to a long random value and register the URL as https://bot.yourcompany.com/webhook?key=YOUR_VALUE.
curl -X POST https://apis.vectalk.com.br/api/webhooks \
  -H "Authorization: Bearer YOUR_VECTALK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"instance_id": "YOUR_INSTANCE", "url": "https://bot.yourcompany.com/webhook?key=YOUR_VALUE", "name": "Python bot"}'

Vectalk expects a 200 within 20 seconds and resends the event if it doesn’t get one, which the deduplication already handles. Create your account at apps.vectalk.com.br/register and check the documentation for the other endpoints. If the next step is answering with AI, the tutorial on connecting ChatGPT to the official WhatsApp API starts from a similar base.

FAQ

Do I need a WhatsApp-specific Python library?

No. The Cloud API is plain HTTP and JSON, so FastAPI to receive and httpx (or requests) to send is enough. Third-party SDKs can save some code, but calling the API directly lets you use new features as soon as Meta ships them.

Can the bot start the conversation?

Free-form text and buttons only work inside the 24-hour window opened by the customer’s last message. To start a conversation, or to reply after that window closes, you must send an approved message template.

How much does it cost to run a bot?

Replies inside the customer service window are service messages. Since October 1, 2026, each number gets 1,000 free service messages per month; from the 1,001st on, the rate for recipients in Brazil is R$ 0.0350 (Brazilian reais) per message. Rates for other countries are on Meta’s pricing page.

Why does webhook verification fail in the dashboard?

The usual causes: the URL isn’t HTTPS with a valid certificate, the server’s VERIFY_TOKEN differs from the one typed in the dashboard, or the route returns the challenge as JSON instead of plain text. That’s why the code uses PlainTextResponse.

Can I use Flask or Django instead of FastAPI?

Yes. The sequence is the same: answer the verification GET, validate the HMAC over the raw body, return 200 fast and call the messages endpoint. In Flask, the raw body comes from request.get_data().


Official sources checked on October 28, 2026: Create a webhook endpoint (Meta), Webhooks overview (Meta), Interactive reply buttons messages (Meta), Interactive messages webhook (Meta), Typing indicators (Meta), Business-scoped user IDs (Meta), Pricing (Meta), FastAPI, Caddy: reverse proxy quick-start, Vectalk: webhooks, Vectalk: authentication, Vectalk: reply buttons.

Try the official WhatsApp API today

Create your account, generate a token and send your first message with the quick-start guide. Same payloads and webhooks as Meta's Cloud API.