A Python (FastAPI) API that connects the WhatsApp Cloud API to three LLM providers — OpenAI, Anthropic and Google Gemini — with runtime provider switching, automatic fallback, conversation memory, voice-note transcription and image/PDF understanding.
WhatsApp ──► POST /webhook ──► queue ──► handler ──► OpenAI | Anthropic | Gemini
▲ (200 right away) │
└────────────── send_text ◄────────────────┘
The bot itself speaks Portuguese (system prompt,
/ajuda,/provedor, user-facing replies). Code, comments and this documentation are in English. Changing the bot's language means editingDEFAULT_SYSTEM_PROMPTinapp/config.pyand the strings inapp/services/.
- What it does
- Layout
- Install
- Setting up WhatsApp on Meta
- Running
- In-chat commands
- HTTP endpoints
- How it works inside
- Supported models
- Tests
- Before going to production
| Feature | Detail |
|---|---|
| 3 providers | OpenAI, Anthropic and Gemini behind one interface (app/llm/base.py) |
| Fallback | If the primary provider fails (quota, timeout, 5xx), the next one in line answers |
| Memory | Per-contact history in SQLite; whatever falls out of the window becomes a summary |
| Audio | Voice notes are downloaded and transcribed before reaching the model |
| Vision | Images and PDFs are passed natively to the multimodal model |
| Commands | /provedor, /modelo, /persona, /reset… straight from WhatsApp |
| Security | X-Hub-Signature-256 validated, number allow-list, API key on the /api routes |
| Robustness | Webhook deduplication, background queue, splitting of messages over 4096 chars |
wabot_python/
├── app/
│ ├── main.py # FastAPI + lifespan (database, providers, queue)
│ ├── config.py # every env var, with defaults
│ ├── api/
│ │ ├── webhook.py # GET (verification) and POST (events) from Meta
│ │ ├── admin.py # /api/chat, /api/messages, /api/conversations
│ │ ├── health.py # /health diagnostics
│ │ └── deps.py # object graph + API key guard
│ ├── llm/
│ │ ├── base.py # ChatTurn, Attachment, LLMReply, LLMProvider
│ │ ├── openai_provider.py
│ │ ├── anthropic_provider.py
│ │ ├── gemini_provider.py
│ │ └── registry.py # provider choice, fallback chain, transcription
│ ├── whatsapp/
│ │ ├── client.py # sending, "typing…", media download
│ │ ├── schemas.py # Meta payload -> IncomingMessage
│ │ └── security.py # webhook HMAC
│ ├── services/
│ │ ├── handler.py # orchestrates the answer
│ │ ├── dispatcher.py # async queue with a per-number lock
│ │ ├── conversation.py # context window + summarization
│ │ ├── media.py # audio/image/PDF -> prompt
│ │ └── commands.py # in-chat commands
│ └── db/ # async SQLAlchemy (models, repo, engine)
├── tests/ # 43 tests, no live API calls
├── run.py # python run.py --reload
└── .env.example
# 1) virtual environment
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # Linux/macOS
# 2) dependencies
pip install -r requirements.txt
# 3) configuration
copy .env.example .env # Windows (cp on Linux/macOS)Requires Python 3.10+.
-
Create the app at developers.facebook.com → Create app → type Business → add the WhatsApp product.
-
Under WhatsApp → API Setup you get a test number and a temporary token (24 h). Note the Phone number ID →
WA_PHONE_NUMBER_ID. -
Add your own phone under To (recipient numbers) so you can test.
-
Permanent token: Business settings → System users → create an admin system user, grant it access to the app and generate a token with the
whatsapp_business_messagingandwhatsapp_business_managementpermissions →WA_ACCESS_TOKEN. -
App secret: App settings → Basic → App secret →
WA_APP_SECRET. -
Expose your local server (Meta requires public HTTPS):
ngrok http 8000 # or: cloudflared tunnel --url http://localhost:8000 -
Webhook: WhatsApp → Configuration → Webhook → Edit
- Callback URL:
https://YOUR-TUNNEL/webhook - Verify token: the same value as
WA_VERIFY_TOKEN - Click Verify and save (the app must be running)
- Under Manage, subscribe to the
messagesfield ✅
- Callback URL:
Meta only delivers webhooks over HTTPS with a valid certificate —
ngrokorcloudflaredhandle that in development.
Fill in at least one LLM key in .env (OPENAI_API_KEY, ANTHROPIC_API_KEY or
GEMINI_API_KEY). /health reports which ones were picked up.
python run.py --reload
# or
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reloadCheck the diagnostics:
curl http://localhost:8000/health{
"status": "ok",
"providers": {"default": "openai", "available": ["openai", "gemini"]},
"transcription": {"provider": "openai", "ready": true},
"queue_pending": 0
}Send "oi" to the test number on WhatsApp and watch the log.
docker build -t wabot .
docker run --env-file .env -p 8000:8000 -v "%cd%/data:/app/data" wabot| Command | What it does |
|---|---|
/ajuda |
Lists the commands |
/status |
Provider, model, context size, whether a summary exists |
/provedor gemini |
Switches provider for this thread (/provedor padrão restores the default) |
/modelo claude-opus-5 |
Pins a specific model |
/modelos |
Model suggestions per provider |
/persona responda como um professor |
Replaces the system prompt for this thread |
/persona limpar |
Restores the default prompt |
/reset |
Wipes the conversation history |
Commands cost no tokens: they are resolved before any LLM call.
| Method | Route | Purpose |
|---|---|---|
GET |
/webhook |
Meta's verification handshake |
POST |
/webhook |
Receives events (validates signature, queues, answers 200) |
GET |
/health |
Diagnostics |
POST |
/api/chat |
Talk to the LLM without WhatsApp |
POST |
/api/messages |
Send a message from the bot's number |
GET |
/api/conversations |
List conversations |
GET |
/api/conversations/{wa_id}/messages |
History of one conversation |
DELETE |
/api/conversations/{wa_id}/history |
Clear the history |
Interactive docs at http://localhost:8000/docs.
The /api/* routes require an X-API-Key header when ADMIN_API_KEY is set.
# test provider routing without WhatsApp
curl -X POST http://localhost:8000/api/chat \
-H "Content-Type: application/json" -H "X-API-Key: your-key" \
-d '{"provider":"anthropic","messages":[{"role":"user","content":"explain entropy in 2 sentences"}]}'POST /webhookvalidates the HMAC over the raw body, turns Meta's JSON intoIncomingMessageobjects and answers 200 immediately — Meta redelivers an event when the endpoint is slow, which is what makes most bots answer twice.- The dispatcher processes messages in the background with N workers and one
asyncio.Lockper number, so a single person's messages stay in order. - The handler records the
wamidinprocessed_events(deduplication), marks the message as read and turns on the typing indicator. - Commands answer straight away. Otherwise the media service resolves the attachment:
audio → transcription, image/PDF →
Attachment, txt/json → inlined in the prompt. - The conversation service assembles the prompt: persona + summary + last N turns.
- The registry calls the thread's provider; on
ProviderErrorit walks downPROVIDER_FALLBACK_ORDER. - The answer is split into ~3,800-character chunks and sent; everything is persisted.
- Past
SUMMARIZE_AFTER_MESSAGES, the old part of the history is compressed into a short summary — the context stops growing without the conversation losing the thread.
Any id the provider's API accepts works. Defaults from .env.example:
| Provider | Default | Others | Audio | Image | |
|---|---|---|---|---|---|
| OpenAI | gpt-5.6-terra |
gpt-6-astra, gpt-5.6-sol, gpt-5.6-luna |
✅ (gpt-transcribe) |
✅ | ✅ |
| Anthropic | claude-sonnet-5 |
claude-opus-5, claude-haiku-4-5-20251001 |
❌ (another provider transcribes) | ✅ | ✅ |
| Gemini | gemini-3.5-flash |
gemini-3.8-flash, gemini-2.5-pro |
✅ (native) | ✅ | ✅ |
OPENAI_BASE_URL points the OpenAI adapter at any compatible endpoint — LM Studio, vLLM,
Ollama — if you would rather run a local model instead.
pytest -q # 43 tests, none of them hit an external API
ruff check .They cover: parsing of Meta's real payloads (text, image, audio, document, button, status, unknown fields), HMAC validation, long-message splitting, deduplication, history, commands, transcription, provider failure, the allow-list and the HTTP routes.
WA_APP_SECRETis mandatory. Without it, anyone who finds your URL can make the bot answer — and spend your tokens.- 24-hour window: you may only send free-form text within 24 h of the user's last
message. Outside it, only approved templates work —
/api/messageswill fail. - Use a permanent token (system user); the dashboard token expires in 24 h.
WA_ALLOWED_NUMBERSrestricts who the bot answers while you are testing.- SQLite is fine for a personal or team bot. For higher volume, point
DATABASE_URLat Postgres (postgresql+asyncpg://…) — the code is already async and storage-agnostic. - Run it behind a TLS-terminating proxy and keep
ADMIN_API_KEYset.