Guide · Python backend
WhatsApp Business API Webhooks with FastAPI in Production
Key takeaways
- One URL, two jobs: a GET verification handshake, then signed POSTs you check against the raw body.
- Store every message ID under a unique constraint, return 200 straight away, and do the real work in Celery.
- Free-form replies only work inside the customer service window; outside it you need an approved template.
- Media arrives as an ID, not a file: fetch it promptly, keep your own copy, and backfill what didn't finish.
By Prasanna Patil · published
Why WhatsApp webhooks are different
A generic webhook guide gets you most of the way, but the WhatsApp Business API (Meta's Cloud API) adds rules of its own, and each comes with a failure mode:
- A verification handshake. When you register the callback URL, Meta sends a GET with
hub.mode,hub.verify_tokenandhub.challenge. If the mode issubscribeand the token matches yours, echo the challenge back with a 200. - Signed deliveries. Every event is a POST with an
X-Hub-Signature-256header:sha256=plus an HMAC-SHA256 of the raw request body, keyed with your app secret. - Batched, nested payloads. One POST holds
entry[]→changes[]→value, and a value can carry severalmessages[](inbound) andstatuses[](sent, delivered, read or failed, for messages you sent). Code that only reads the first element eventually drops something. - At-least-once delivery. A delivery that doesn't get a timely 200 is retried, so the same message can arrive twice — and you shouldn't assume deliveries arrive in order.
The background: at Shivohini TechAI I built WhatsApp automation for clients — webhook ingestion, message routing, template management via Meta Business API — and moved message processing, AI inference, and DB writes off the request thread with Redis and Celery. At Zuneko Labs I built a WhatsApp-based document ingestion system using the WhatsApp Business API to pull media from group chats and upload it to Google Drive. The code here is the general pattern, simplified, not either project's code.
The receiver: verify, persist, acknowledge
The receiver has one job: turn an HTTP delivery into a durable row, fast. LLM calls, Graph API requests and media downloads all happen later, in a worker.
@router.get("/webhooks/whatsapp")
async def verify(request: Request):
q = request.query_params
if q.get("hub.mode") == "subscribe" and q.get("hub.verify_token") == settings.WA_VERIFY_TOKEN:
return PlainTextResponse(q.get("hub.challenge", ""))
raise HTTPException(status_code=403)
@router.post("/webhooks/whatsapp")
async def receive(request: Request):
raw = await request.body() # the exact bytes Meta signed
expected = "sha256=" + hmac.new(settings.WA_APP_SECRET.encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Hub-Signature-256", "")):
raise HTTPException(status_code=401)
for entry in json.loads(raw).get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
for msg in value.get("messages", []):
# INSERT ... ON CONFLICT (wa_message_id) DO NOTHING RETURNING id, committed here
row_id = db.insert_message_if_new(msg["id"], value["metadata"]["phone_number_id"], msg)
if row_id:
process_message.delay(row_id) # enqueue only after the commit
for st in value.get("statuses", []):
db.record_status_if_new(st["id"], st["status"], st) # unique on (message id, status)
return Response(status_code=200) # duplicates and unhandled types get a 200 too- Raw bytes first. Hash the body before anything parses it, and compare with
hmac.compare_digest. Payloads can contain escaped Unicode, so a parse-and-re-serialise round trip changes the bytes and breaks the signature. - The unique constraint is the dedup. Each message has its own ID (
wamid), so a unique index on it makes a retried delivery a no-op. Status updates reuse the ID of the message they describe, so deduplicate them on message ID plus status. - Commit, enqueue, then 200. If the database is down, return an error and let Meta retry; a 200 for something you didn't persist is how messages get lost for good. If the enqueue fails after the commit, the row waits in
queuedfor the recovery job. - A bad signature is not a 200. If the mismatch is your bug — the wrong app's secret, a proxy rewriting the body — rejected deliveries are retried, so a prompt fix recovers them.
Three secrets, three jobs: the verify token answers the handshake, the app secret checks signatures, and the access token calls the Graph API. None of them belong in the repository.
Routing by message type and user state
In the worker, order matters more than framework: load the row, check the idempotency guard, update the user, then route.
SERVICE_WINDOW = timedelta(hours=24) # check Meta's current docs; keep it in one place
HANDLERS = {
"text": handle_text, # meaning depends on the user's state
"interactive": handle_reply_id, # button/list replies: route on the ID you set
"button": handle_template_button, # quick-reply button on a template
"image": queue_media, "document": queue_media, "audio": queue_media, "video": queue_media,
}
@app.task(acks_late=True, autoretry_for=(TransientError,), retry_backoff=True, max_retries=5)
def process_message(row_id: int):
msg = db.get_message(row_id)
if msg.status == "done": # a re-delivered task is a no-op
return
user = users.lock_or_create(msg.wa_id) # row lock: one message per user at a time
users.bump_last_inbound(user, msg.sent_at) # GREATEST(): never moves backwards
HANDLERS.get(msg.type, handle_unsupported)(msg, user)
db.mark_done(row_id)
def reply(user, text: str):
if user.last_inbound_at and now() - user.last_inbound_at < SERVICE_WINDOW:
return wa.send_text(to=user.wa_id, body=text)
# Outside the window, only a pre-approved template can reach the user.
return wa.send_template(to=user.wa_id, name="conversation_follow_up", language="en", params=[user.first_name])Type alone isn't enough. A “yes” means one thing after “confirm this order?” and another in open conversation. Keep a small, explicit set of states per user in PostgreSQL — in a menu, partway through a form, chatting with an AI agent, handed off to a human — and route on type and state together. A handed-off user should get no bot replies at all, which is easier to guarantee as a state than as a flag a few handlers remember to check.
- Route interactive replies on IDs. Button and list replies carry the ID you set when you sent them; titles are for humans and get reworded.
- Serialise per user. A row lock on the user stops two workers racing one person's messages through the same state transition.
- Don't trust arrival order. Use the payload timestamp, and never let an older message move the state backwards.
- Have a fallback. Stickers, reactions and types you didn't plan for need a polite default, not an exception that burns through retries.
Templates vs free-form replies
When a user messages you, a customer service window opens (24 hours from their last message, at the time of writing), and inside it you can send free-form messages. Outside it you can only use a template: a message Meta has reviewed and approved in advance, with variables you fill in per send. Messaging users proactively also requires their opt-in under WhatsApp's policies.
- Decide in one place. Track the last inbound time per user, as the worker above does, and choose free-form or template inside a single send function, not in every handler.
- Treat templates as configuration. Map each intent — a follow-up, a reminder, a document request — to a template name, language and parameter list in config or a table. Each language version is reviewed separately, so plan for it before launch.
- Expect changes after approval. A template can be paused or disabled later, for example on quality. Subscribe to template status updates on the webhook and alert on them, or a follow-up flow fails quietly.
- Accepted is not delivered. A successful send call only means Meta accepted the message; deliveries, reads and many failures arrive later as status webhooks. Store each outbound message ID and update it from those.
Media handling: an ID now, a file only if you hurry
An inbound image, document, audio or video message carries a media ID, the MIME type and, for documents, a filename — not the file. Getting the bytes takes two authenticated calls: ask the Graph API about the media ID, with your access token as a bearer token, to get a short-lived download URL; then download that URL with the same token. Upload the file to your own storage, such as Google Drive, and write the storage ID back to the row.
graph = httpx.Client(
base_url=f"https://graph.facebook.com/{settings.GRAPH_API_VERSION}",
headers={"Authorization": f"Bearer {settings.WA_ACCESS_TOKEN}"},
timeout=60,
)
@app.task(acks_late=True, autoretry_for=(httpx.TransportError, StorageUnavailable), retry_backoff=True, max_retries=8)
def store_media(row_id: int):
msg = db.get_message(row_id)
if msg.drive_file_id: # already stored: uploading again makes a duplicate
return
info = graph.get(f"/{msg.media_id}") # media ID -> fresh, short-lived URL; never cache it
info.raise_for_status()
media = graph.get(info.json()["url"]) # download now, with the same bearer token
media.raise_for_status()
file_id = drive.upload(media.content, name=msg.filename or msg.media_id, mime_type=info.json()["mime_type"])
db.set_drive_file(row_id, file_id)
@app.task
def backfill_media(): # scheduled with Celery beat
for row_id in db.ids("""
SELECT id FROM inbound_messages
WHERE media_id IS NOT NULL AND drive_file_id IS NULL
AND received_at < now() - interval '15 minutes' AND attempts < 10
"""):
store_media.delay(row_id)- Download inside the task. Never store the URL for later. A retry that runs after it expired just asks for a fresh one from the media ID.
- Don't fall behind. Meta doesn't keep media indefinitely, so a pipeline that lags long enough can't catch up. Alert when the backlog grows.
- Guard the upload. Google Drive happily stores two files with the same name, so a retry without the stored-ID check creates duplicates.
- Mind large files. Use resumable uploads and stream to disk; the sketch holds the whole file in memory for brevity.
Recovery: backfill what downtime missed
Queues can't help with work that never reached the queue. There are two different gaps:
- Deliveries that never reached you. While your endpoint is down, Meta retries for a limited period, so a short outage mostly means late messages, not lost ones — as long as you never answered 200 for something you didn't persist. Don't plan on re-reading message history from the API later; treat webhook downtime as an incident with its own alert.
- Work that arrived but didn't finish. A worker died, the broker lost an enqueue, Google Drive or the network failed mid-upload, a media URL expired between retries. Each leaves a row that exists but isn't done, and because every task is idempotent, a scheduled job can safely run it again:
backfill_mediaabove for files, and the same query shape over rows stuck inqueuedfor messages.
Cap the attempts, and move rows that keep failing to a failed state someone actually watches. At Zuneko Labs I wrote a recovery mechanism to backfill missed files after downtime or network failures; the sketch shows the general shape of such a job, not that system's code.
What I'd check before go-live
- The handshake echoes the challenge, and a tampered body or wrong signature is rejected.
- Replaying the same payload twice creates one row and one reply.
- The receiver makes no outbound calls — no Graph API, no LLM, no Drive — before it returns.
- The app is subscribed to the fields you rely on: messages, plus template status updates if you send templates.
- Production uses a system user access token, not the developer dashboard's temporary token, which expires.
- Every send passes the window check, and every template is approved in each language you use.
- Celery runs with late acks and idempotent tasks; failed sends and stuck rows alert someone.
- Logs carry the WhatsApp message ID end to end, so one complaint can be traced from webhook to reply.
Limitations of this guide
It covers Meta's Cloud API webhooks, called directly. Providers that wrap WhatsApp, such as Twilio, reshape the payloads and handle some of this for you. API versions, webhook fields, window rules and pricing change, so check Meta's current documentation and treat the payloads here as simplified. Throughput limits, quality ratings, WhatsApp Flows, payments, group-specific behaviour and compliance questions such as data retention aren't covered.
Frequently asked questions
Can one webhook URL handle several WhatsApp numbers?
Yes. Events for every number your app is subscribed to can arrive at the same endpoint, and each change carries the receiving number's phone_number_id in its metadata. Store it with the message and route on it, so replies go out from the number the user wrote to.
Why does the same WhatsApp message arrive twice?
Delivery is at-least-once: a slow, failed or unreachable delivery is retried, and a retry can land after the original was processed. Keep the message ID under a unique constraint and answer repeats with a 200 that does nothing.
Can I send the reply in the webhook response?
No. The webhook only needs a 200; the response body isn't a reply channel. Replies are a separate call to the Cloud API's messages endpoint, which is why they belong in a background worker.
How long do I have to download media a user sent?
Not long. The download URL from the Graph API expires within minutes, though the media ID can get you a fresh one. Meta doesn't keep media indefinitely either, so download it during normal processing and keep your own copy.
WhatsApp messages going missing?
Tell me which events you receive, what happens to them, and where messages or files disappear.