A very small FastAPI service that answers WhatsApp messages through GREEN-API with predefined flows only — no AI, no database, no background workers.
What it does:
- A new customer sends any text → the bot replies with a bilingual language menu (two tappable buttons: עברית / English).
- The customer picks a language → the bot sends the interest menu in that language (three tappable buttons: Pilates / Barre / instructor course).
- The customer picks an interest → the bot sends that flow's description plus its own three follow-up buttons (pricing, trial class, talk to us …).
- The customer picks a follow-up → the bot sends the answer, or hands the chat to a human if they asked to talk to someone.
- Typing 0 at any point goes back to the main menu.
- The moment the business owner replies manually from WhatsApp, the bot goes silent for that chat.
- Python 3.11+
- A GREEN-API instance (instance id + API token)
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill in your GREEN-API credentials
uvicorn app.main:app --reloadThen:
- Health check: http://127.0.0.1:8000/health
- API docs: http://127.0.0.1:8000/docs
Run the tests (GREEN-API is mocked, no real WhatsApp messages are sent):
pytest| Variable | Secret | Description |
|---|---|---|
GREEN_API_INSTANCE_ID |
yes | Your GREEN-API instance number. |
GREEN_API_TOKEN |
yes | Your GREEN-API apiTokenInstance. |
GREEN_API_API_URL |
yes | API host, e.g. https://api.green-api.com or your instance host https://7105.api.greenapi.com. |
ADMIN_API_KEY |
yes | Any long random string. Required by the /admin/* endpoints. |
DATA_FILE_PATH |
no | Where customer state is written. data/users.json on Render Free. |
GREEN_API_MEDIA_URL |
no | Host that files are uploaded to. Blank derives it from the API URL. |
ASSETS_DIR |
no | Where images the bot sends live. assets by default. |
LOG_LEVEL |
no | INFO by default. DEBUG also logs every webhook body and GREEN-API response. |
Never commit .env. A blank value (GREEN_API_API_URL=) is treated as "not set" and
falls back to the default, and a host pasted without a scheme gets https:// added.
| Method | Path | Purpose |
|---|---|---|
GET |
/health |
Render health check. Returns {"status": "ok"}. Never contacts GREEN-API. |
POST |
/webhook/green-api |
GREEN-API notifications. Always returns HTTP 200. |
GET |
/admin/diagnostics |
Start here when something is wrong. Checks the whole setup. |
GET |
/admin/user/{chat_id} |
Read a customer's stored state. |
POST |
/admin/pause/{chat_id} |
Stop automatic replies for a customer. |
POST |
/admin/resume/{chat_id} |
Re-enable automatic replies. |
POST |
/admin/reset/{chat_id} |
Forget a customer so onboarding starts again. |
Admin endpoints require the header X-Admin-Key: <ADMIN_API_KEY>:
curl -H "X-Admin-Key: $ADMIN_API_KEY" https://<service-name>.onrender.com/admin/user/972501234567@c.usGREEN-API distinguishes the two kinds of outgoing message, and the bot relies on that:
outgoingMessageReceived— the owner typed the message on their phone / WhatsApp Web / Desktop. The bot immediately setsbot_enabled: falseandflow: "human"for that chat and stops replying.outgoingAPIMessageReceived— the message was sent by this application through GREEN-API. The bot is not disabled.
To bring the bot back for that customer, call POST /admin/resume/{chat_id} (keeps the
conversation state) or POST /admin/reset/{chat_id} (starts onboarding over).
Render Free Web Services have an ephemeral filesystem. data/users.json is wiped
whenever the service spins down, restarts, or redeploys. When that happens a returning
customer looks like a new inquiry and gets the language menu again.
This is intentional for the MVP. There is deliberately no Git/GitHub/database workaround.
All state access goes through app/storage/json_store.py, so upgrading later is a
configuration change, not a rewrite:
- Upgrade the Render instance to a paid plan.
- Attach a Persistent Disk mounted at
/var/data. - Set
DATA_FILE_PATH=/var/data/users.json.
No bot or flow code changes.
1. Ask the service what is wrong. This one endpoint checks the configuration, the GREEN-API instance state and the instance's webhook settings, and answers in plain words:
curl -H "X-Admin-Key: $ADMIN_API_KEY" https://<service-name>.onrender.com/admin/diagnostics{
"status": "problems_found",
"problems": [
"GREEN-API instance state is 'notAuthorized', expected 'authorized'. Scan the QR code again in the GREEN-API console.",
"The instance webhookUrl is 'https://old-bot.example.com/hook', which does not end in /webhook/green-api - notifications are going somewhere else.",
"GREEN-API setting outgoingMessageWebhook is 'no', expected 'yes' - needed to detect a manual reply and switch the bot off."
]
}"status": "ok" with an empty problems list means the setup is fine and the issue is
in the conversation itself — read the logs.
2. Read the logs. On Render: your service → Logs. Every startup prints the resolved configuration (never the secrets themselves):
INFO app.main: WhatsApp flow bot starting
INFO app.main: GREEN_API_API_URL = https://api.green-api.com
INFO app.main: GREEN_API_INSTANCE_ID = 1101234567
INFO app.main: GREEN_API_TOKEN = set
ERROR app.main: CONFIG PROBLEM: ADMIN_API_KEY is not set - the /admin endpoints will answer 503.
and every message is traceable end to end:
INFO app.routers.webhook: Webhook received: type='incomingMessageReceived' chatId='972501112222@c.us' idMessage='F2' typeMessage='interactiveButtonsReply'
INFO app.services.bot: Handling '1' from 972501112222@c.us (step=1, language=None, flow=None)
INFO app.services.bot: 972501112222@c.us chose language he
INFO app.services.green_api: Sending 3 buttons to 972501112222@c.us: ['Pilates מכשירים', 'Barre', 'קורס הכשרת מדריכות']
INFO app.services.green_api: GREEN-API -> POST https://api.green-api.com/waInstance1101234567/sendInteractiveButtons/***TOKEN***
INFO app.services.green_api: Sent button menu BAE5A7BA6D6E28EB to 972501112222@c.us
The API token is masked as ***TOKEN*** everywhere, including inside error messages.
Set LOG_LEVEL=DEBUG to also log every webhook body and every GREEN-API response body.
3. Common causes, and the log line that shows each one.
| Symptom in the log | Cause |
|---|---|
CONFIG PROBLEM: GREEN_API_TOKEN is not set |
The env var is missing on Render. |
GREEN_API_API_URL looks wrong |
The URL has no http:// or https://. |
returned HTTP 401 |
GREEN_API_TOKEN does not match the instance. |
returned HTTP 404 |
GREEN_API_INSTANCE_ID or GREEN_API_API_URL is wrong. |
returned HTTP 466 |
The GREEN-API instance is out of quota. |
Bot is OFF for … - ignoring the message |
A manual reply switched the bot off. POST /admin/resume/{chat_id} turns it back on. |
Ignoring imageMessage from … |
The customer sent media; the bot only reads text and button taps. |
Duplicate notification … - already answered |
GREEN-API redelivered a notification. Working as intended. |
| Nothing at all in the log | The webhook never arrived — check webhookUrl and incomingWebhook in /admin/diagnostics. |
Every webhook still answers HTTP 200 (so GREEN-API stops retrying), but a failed one
returns {"status": "error", "reason": "..."} naming the cause.
Both menus are sent with GREEN-API's
sendInteractiveButtons.
GREEN-API limits a message to 3 buttons, each with a buttonText of at most 25
characters; app/services/green_api.py enforces both before sending.
GREEN-API marks that method as beta, so the bot never depends on it:
- If the button call fails, the same menu is re-sent as a numbered text message
(
1. …/2. …/3. …) built from the very same wording — nothing to maintain twice. - A customer is understood whether they tap a button, type the number, or type the button's label ("English", "barre").
A tap arrives as its own notification type, not as text, and GREEN-API has several names
for one — including interactiveButtonsResponse, which production instances send but no
published documentation page describes. So the bot does not match on typeMessage:
it scans every nested object of messageData for a selection key
(selectedButtonId, selectedId, selectedRowId, buttonId, or their text
equivalents). That covers the documented shapes —
typeMessage |
Where the choice is |
|---|---|
buttonsResponseMessage |
selectedButtonId / selectedButtonText |
templateButtonsReplyMessage |
selectedId / selectedDisplayText |
listResponseMessage |
singleSelectReply.selectedRowId |
interactiveButtonsResponse |
undocumented; found by the same scan |
— and a new one GREEN-API invents next will very likely work unchanged.
Lists are never scanned, so a menu echoed back to us (interactiveButtons /
interactiveButtonsReply, which carry every button and select none) can never be
mistaken for a choice. Anything the bot still cannot read logs
Could not read a choice out of ... with the complete messageData — the one thing
needed to add support for that shape.
assets/class_schedule.jpeg is sent automatically after the answer to any
schedule button, via GREEN-API's
sendFileByUpload.
The file is uploaded from disk, so nothing depends on the repository being public.
Note this goes to the media host, not the API host — see GREEN_API_MEDIA_URL above.
Which button sends which image is data, in FLOW_ANSWER_IMAGES in each catalogue:
FLOW_ANSWER_IMAGES = {
"pilates": {"2": "class_schedule.jpeg"}, # מערכת שעות
"barre": {"1": "class_schedule.jpeg"}, # מחירים ומערכת שעות
}One sheet covers both Pilates and Barre, so all four schedule buttons send it: Hebrew
Pilates 2, Hebrew Barre 1, and English Pilates and Barre 1. To attach an image to
another button, drop the file in assets/ and add a line here. To replace the
schedule, overwrite assets/class_schedule.jpeg — the filename is what the code looks up.
The image is sent after the text answer, and a failure to send it is logged rather than
raised, so a missing file never swallows the answer that already went out. Startup and
/admin/diagnostics both report any configured image that is not on disk, and a test
asserts every configured file exists.
Typing 0 at any step returns the customer to the main (interest) menu, clearing the
flow they were in. It works before they have finished — and after, when the bot would
otherwise stay quiet. Before a language has been picked there is no main menu yet, so
0 re-asks the language question instead.
0 is not a button: GREEN-API allows only three per message and all three are in use.
It is advertised in each menu's footer (MENU_FOOTER in the catalogues) and is matched
whether typed or arriving as a tapped button id.
One deliberate exception: 0 does not wake the bot in a chat a human has taken
over. Only POST /admin/resume/{chat_id} does that.
All customer-facing text lives in app/messages/he.py and app/messages/en.py:
| Name | What it is |
|---|---|
LANGUAGE_BODY / LANGUAGE_BUTTONS |
The opening bilingual menu. |
INTEREST_BODY / INTEREST_BUTTONS |
The three interest buttons. |
FLOW_BODY[flow] |
The description sent when that interest is chosen. |
FLOW_BUTTONS[flow] |
That flow's own follow-up buttons. |
PRICING |
The class price list. Both Pilates and Barre point at it, so a price is one edit. |
FLOW_ANSWERS[flow][buttonId] |
What the bot answers for a follow-up — a list of 1–3 messages. An empty list means the image alone is the answer. |
HANDOVER_BUTTONS[flow] |
Follow-up buttons that fetch a human instead of answering. |
HANDOVER_MESSAGE |
Sent just before a human takes over. |
MENU_FOOTER |
The "0 goes back" hint under the interest and flow menus. |
Everything still marked [TODO: ...] needs the real wording — those are the answers
behind each follow-up button, and the handover message.
Each button's buttonId is what routes the customer: on the interest menu 1 → pilates,
2 → barre, 3 → instructor_course (see FLOW_BY_BUTTON_ID in app/services/bot.py).
Change a buttonText freely; change a buttonId only together with the matching
FLOW_ANSWERS / HANDOVER_BUTTONS entry. Keep labels within 25 characters and each menu
at three buttons or fewer — a test enforces both, and that every button leads somewhere.
The two catalogues are deliberately allowed to differ: the Hebrew Pilates menu offers
מחירים ומנויים / מערכת שעות / לקבוע שיעור ניסיון, while the English one offers
Pricing & class schedule / Book a trial class / Talk to us. Only the English Pilates
menu has a "talk to us" button.
-
Push this repository to GitHub.
-
Open the Render Dashboard.
-
New → Blueprint, connect this repository. Render reads
render.yaml. -
Confirm the instance type is
Free(plan: freeis already set inrender.yaml). -
Render prompts for the values marked
sync: false— enter yourGREEN_API_INSTANCE_ID,GREEN_API_TOKEN,GREEN_API_API_URLandADMIN_API_KEY. -
Apply / Deploy.
-
Verify the service is up:
https://<service-name>.onrender.com/healthIt must return
{"status": "ok"}. -
In the GREEN-API console, set the instance Webhook URL to:
https://<service-name>.onrender.com/webhook/green-api -
Enable these instance settings in GREEN-API:
incomingWebhook— yes (receive customer messages)outgoingMessageWebhook— yes (required for human takeover detection)outgoingAPIMessageWebhook— yesstateWebhook— optional (ignored by the bot)
Render binds the port it gives you through $PORT; the start command in render.yaml is
uvicorn app.main:app --host 0.0.0.0 --port $PORT. Port 8000 is never hardcoded for production.
Free services sleep after inactivity, so the first webhook after a sleep may take a few seconds to be answered while the service wakes up.
app/
main.py FastAPI app, /health, router wiring
config.py Pydantic Settings
routers/
webhook.py POST /webhook/green-api
admin.py /admin/* endpoints (X-Admin-Key)
services/
green_api.py All GREEN-API HTTP calls (httpx)
bot.py Flow rules, human takeover
storage/
json_store.py JSON state, atomic writes, duplicate detection
messages/
he.py / en.py All customer-facing text
data/ Temporary state (users.json, gitignored)
tests/test_bot.py Flow tests with GREEN-API mocked
render.yaml Render Blueprint (Free plan, no disk, no database)