How to send a message in Python with the Wapito WhatsApp API
A script, a token and an HTTP client is the whole stack. Everything below runs from a file you can execute today, and the same code moves to a worker or a container without changing shape.
Sending free-form messages from Python is one requests.post per message, and the Python that matters is the loop around it: sequential sends with a pause, a hard cap on the number of messages a run may send, and a FastAPI receiver that treats the status event as delivery. A dict from message id to recipient ties the two halves together, and a dry-run flag protects the first run.
Before you start
pip install requestsos.environ["WAPITO_TOKEN"]- A channel with a WhatsApp number linked to it, and its API token. Create one in the dashboard — the authentication guide shows where the token goes.
How it works
Send the text you actually wrote
One call, one message, no template id and no approval queue. The body is whatever you want to say, which means the copy can change with the deploy rather than with Meta's review cycle.
import requests url = "https://api.wapito.com/v1/messages/text" payload = { "to": "+15551234567", "body": "Your order #4182 has shipped. Track it here: https://acme.example/t/4182", "typing_time": 3 } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python send with requests.post(url, json={"to": number, "body": text}) and keep response.json()["id"] in a dict keyed to the recipient; the status webhook references that id. The text is a plain str, so f-strings with the customer's name are the whole templating system, and nothing has to be approved first. A typing_time of a few seconds reads naturally.
API reference for this stepAttach media in the same flow
Images, video, documents and voice notes all follow the same recipient-plus-payload shape. Upload once and reuse the media id when the same asset goes to many recipients.
import requests url = "https://api.wapito.com/v1/messages/image" payload = { "to": "+15551234567", "media": "https://acme.example/labels/4182.png", "caption": "Your shipping label for order #4182" } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python send an image or a document with the media id and a caption in the json= dict, the same recipient-plus-payload shape as text; upload the file once and reuse the id across recipients within a run. A voice note follows the same shape with an Opus file behind the id, and filename names a document.
API reference for this stepAsk a structured question
A poll turns a question into machine-readable answers, which is far more reliable than asking people to reply with a number and then parsing whatever they type.
import requests url = "https://api.wapito.com/v1/messages/poll" payload = { "to": "120363041234567890@g.us", "title": "When should we run the launch standup?", "options": ["Monday 09:00", "Tuesday 10:00", "Wednesday 16:00"], "multiple": False } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python ask with requests.post on /messages/poll and a dict holding the title, an options list and a multiple flag; the vote events come back keyed to the message id, so store it with the recipient. Counting votes is then a dict lookup rather than parsing free text with regular expressions, and collections.Counter does the tally in one line.
API reference for this stepFollow delivery on the webhook
A 2xx from the send endpoint means accepted, not delivered. The status event carries the real outcome keyed by message id, and it is what your retry logic should watch.
Arrives on your webhook as
messages.status.In Python the FastAPI handler receives messages.status events; look up payload["data"]["id"] in your table and set the delivered or failed state from the event, in a background task, then return the 2xx. Retry logic should read this table, never the send response, and a failed state carries a reason that says whether the recipient or the channel was at fault.
The whole script
Every step above in one runnable file. Save it as send-message-without-template.py, put your token in the environment, and run it.
"""Send Without Template with the Wapito WhatsApp API.
Send the text you wrote, attach media by id, ask questions as polls, and treat the status webhook rather than the send response as delivery.
Run it with:
export WAPITO_TOKEN="wpt_..."
python send-message-without-template.py
"""
import os
import requests
BASE_URL = "https://api.wapito.com/v1"
TOKEN = os.environ["WAPITO_TOKEN"]
# --- Send the text you actually wrote ---
url = BASE_URL + "/messages/text"
payload = {
"to": "+15551234567",
"body": "Your order #4182 has shipped. Track it here: https://acme.example/t/4182",
"typing_time": 3
}
headers = {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
# --- Attach media in the same flow ---
url = BASE_URL + "/messages/image"
payload = {
"to": "+15551234567",
"media": "https://acme.example/labels/4182.png",
"caption": "Your shipping label for order #4182"
}
headers = {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
# --- Ask a structured question ---
url = BASE_URL + "/messages/poll"
payload = {
"to": "120363041234567890@g.us",
"title": "When should we run the launch standup?",
"options": ["Monday 09:00", "Tuesday 10:00", "Wednesday 16:00"],
"multiple": False
}
headers = {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
Receive the webhook
FastAPI receiver for messages, messages.status, polls — it verifies the signature, answers immediately and does the work after. Install it with pip install fastapi uvicorn and save it as webhook.py.
import hashlib
import hmac
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = os.environ["WAPITO_WEBHOOK_SECRET"].encode()
EVENTS = ["messages", "messages.status", "polls"]
def verify(raw: bytes, header: str | None) -> bool:
"""Checks the X-Wapito-Signature header: t=<ms>,v1=<hex hmac-sha256>."""
if not header:
return False
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig:
return False
if abs(time.time() * 1000 - int(ts)) > 300_000: # 5 minute clock skew
return False
expected = hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
@app.post("/wapito")
async def wapito(request: Request, signature: str | None = Header(default=None, alias="X-Wapito-Signature")):
raw = await request.body()
if not verify(raw, signature):
raise HTTPException(status_code=401, detail="bad signature")
payload = await request.json()
if payload["event"] in EVENTS:
print(payload["event"], payload["data"])
return {"ok": True} # answer 2xx fast; do the real work in a queue
Errors you may hit
Gotchas
- requests does not raise on a 4xx by itself. Call response.raise_for_status() or check response.ok, otherwise a 429 send_rate_limited silently looks like a successful send and your loop keeps hammering the queue.
- json= and data= are not interchangeable. Pass json={...} so requests sets Content-Type: application/json and encodes UTF-8 for you; data= with a dict sends form encoding and the API answers 400 invalid_request.
- The default requests timeout is None, which means a stalled connection hangs your worker forever. Always pass timeout=(5, 30) and treat a read timeout as "maybe delivered" - re-check with the message id before you send again.
- A group id such as 120363000000000000@g.us is a string, not a number. Building it with an f-string from an int drops the precision and you get 404 not_found.
- Emoji and accented text need no extra work in Python 3, but reading a CSV of numbers with the default encoding on Windows will mangle them - open files with encoding="utf-8".
Where to run it
- Google Cloud Run (container, scale to zero, one revision per deploy)
- Render background worker for the sender, Render web service for the webhook receiver
- A 1 GB VPS with systemd for the worker and Caddy in front of the FastAPI receiver
- AWS Lambda + API Gateway for the webhook receiver only (the sender needs a long-lived process for queue spacing)
Pitfalls in Python
- A while True loop with no counter can empty a plan's quota in minutes when a bug makes it re-read the same rows; put a hard cap on sends per run in your own code before the loop, and mark each row sent in the same transaction that stores the id.
- requests without timeout= can hang on one send and the loop stops silently; pass timeout=(5, 30) and treat a Timeout as maybe-sent, checking the message id before resending, because the queue may have accepted the message even though the response never arrived.
- Testing against the production channel with a list you thought was empty is how a loop reaches real customers; point the script at a Sandbox channel through an environment variable until the run is reviewed, and make the script refuse to start when that variable is unset rather than falling back to a default.
Frequently asked questions
Is sending without a template against WhatsApp's rules?
It is outside the official platform, which is a different statement. Wapito drives a real linked device, the same way the desktop app does, and WhatsApp can ban a number it judges to be misbehaving. No provider can promise otherwise, which is why every page here carries a ban-risk note rather than a guarantee.
What replaces the 24-hour window?
Nothing technical - and that is precisely why your own discipline has to. The window existed to stop businesses messaging people who had not asked. Keep an opt-in record, honour opt-outs immediately, and pace first contacts, because the abuse systems still exist even when the window does not.
Can I still use templates if I want to?
There is no template system here to use, because there is no approval layer. If your use case genuinely fits templates - predictable transactional notices to customers who expect them - the official Cloud API is the better tool and we will say so on the comparison page.
Related
Try it on your own number
Create a channel, link a WhatsApp number by QR or pairing code, and call the API in a couple of minutes. The Sandbox plan is free and needs no card.