How to post a status update 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.
Posting a status from Python is four requests.post calls of the same shape with different bodies; the only file handling is the upload, which is a base64 data URI built from pathlib and base64. A cron job that runs once a day fits well, and the same media id can go to a status and to a channel in one 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
Post a text card
A text status is a short line on a coloured background. Keep it under a couple of dozen words: the card is read in a second by someone tapping through, not studied.
import requests url = "https://api.wapito.com/v1/stories/text" payload = { "body": "Workshop closed Friday for stocktake. Orders ship Monday.", "background_color": "#0B7F5C", "font": 2, "contacts": ["+15551234567", "+15559876543"] } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python post a text card with requests.post(url, json={"text": ..., "background": ...}) and keep the returned id; there is nothing to encode by hand because requests serialises the dict. Keep the text short in code with a length check, since a card longer than a couple of dozen words is unreadable.
API reference for this stepUpload the image or video
Upload once and reuse the media id if you post the same asset to status and to a channel. Portrait assets fill the screen; landscape ones are letterboxed and look like an afterthought.
import requests url = "https://api.wapito.com/v1/media" payload = { "data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD…", "filename": "new-colours.jpg" } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python upload by building the data URI from base64.b64encode(Path(path).read_bytes()).decode() with the mime from mimetypes.guess_type, and requests.post it as a JSON body. The returned dict carries the media id, which you can hold in a variable and reuse for a channel post in the same run.
API reference for this stepPost the media status
Attach the uploaded media with a caption. This is the format shops use for a daily menu or new stock, because the picture does the work and the caption carries the price or the time.
import requests url = "https://api.wapito.com/v1/stories/media" payload = { "media": "https://acme.example/status/new-colours.jpg", "caption": "New colours, same price.", "contacts": ["+15551234567"] } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python post the media status with the media id and the caption in the json= dict, catching requests.HTTPError so a 413 payload_too_large or a 415 unsupported_media_type is reported with the file name rather than as a traceback. The caption is where the price or the time goes.
API reference for this stepPost a voice status
A short voice note as a status is unusual enough to get attention and personal enough to be worth it occasionally. The audio must be Opus-encoded, the same as a voice message.
import requests url = "https://api.wapito.com/v1/stories/audio" payload = { "media": "https://acme.example/status/monday-update.m4a", "background_color": "#1D2B3A" } headers = { "Authorization": "Bearer wpt_YOUR_TOKEN", "Content-Type": "application/json" } response = requests.post(url, json=payload, headers=headers) print(response.json())In Python a voice status needs Opus audio; convert with a subprocess call to ffmpeg before uploading, since Python has no built-in encoder for it, then post the media id to /stories/audio. Check the returned status code; the endpoint rejects an MP3 with unsupported_media_type.
API reference for this step
The whole script
Every step above in one runnable file. Save it as post-status.py, put your token in the environment, and run it.
"""Post Status with the Wapito WhatsApp API.
Post a text card, or upload media once and post it as an image, video or voice status with a caption that carries the detail.
Run it with:
export WAPITO_TOKEN="wpt_..."
python post-status.py
"""
import os
import requests
BASE_URL = "https://api.wapito.com/v1"
TOKEN = os.environ["WAPITO_TOKEN"]
# --- Post a text card ---
url = BASE_URL + "/stories/text"
payload = {
"body": "Workshop closed Friday for stocktake. Orders ship Monday.",
"background_color": "#0B7F5C",
"font": 2,
"contacts": ["+15551234567", "+15559876543"]
}
headers = {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
# --- Upload the image or video ---
url = BASE_URL + "/media"
payload = {
"data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD…",
"filename": "new-colours.jpg"
}
headers = {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
# --- Post the media status ---
url = BASE_URL + "/stories/media"
payload = {
"media": "https://acme.example/status/new-colours.jpg",
"caption": "New colours, same price.",
"contacts": ["+15551234567"]
}
headers = {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
# --- Post a voice status ---
url = BASE_URL + "/stories/audio"
payload = {
"media": "https://acme.example/status/monday-update.m4a",
"background_color": "#1D2B3A"
}
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 — 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"]
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
- Reading a large video into memory with read_bytes and base64-encoding it inside a JSON string can exceed the plan's size cap and your worker's memory at the same time; keep status assets small and check the file size first.
- mimetypes.guess_type returns (None, None) for an unknown extension, and formatting None into the data URI sends data:None;base64 which the API answers with 400 invalid_request; fall back to an explicit mime.
- A cron job that runs the script at midnight UTC posts the status at the wrong local time for most of the world; schedule it in the number's timezone.
Frequently asked questions
Who actually sees my status?
People who have saved your number in their contacts and have not restricted status from you. There is no subscriber list and no way to add someone, so growing reach means getting more people to save your number - which is a marketing problem rather than an API one.
Can I schedule status posts?
Not inside WhatsApp, but that is exactly what the API is for: run a scheduler on your side and call the endpoint at the moment you want the post to appear. Since statuses expire after a day, posting at the right hour matters more here than for almost anything else.
Does posting status count as sending?
It is activity on the linked number and is paced through the same queue, so treat it as part of your daily budget rather than as free. It does not consume a per-recipient send the way a broadcast would, which is part of why it is an efficient way to reach saved contacts.
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.