Shipping a business agent on WhatsApp: templates, webhooks and the 24-hour window
We built a genuinely good in-app chat panel for Rai. It sits inside UpeoRetail, it is fast, and the owner has to decide to open it.
That last clause is the whole problem. Deciding costs attention, attention is fully committed to suppliers and staff and cash, and a reporting surface competing for that budget loses structurally - not sometimes, and not because it was badly built.
So the channel that matters is the one already open, and in this market that is WhatsApp. This article is about what it actually takes to put a business agent there properly, including the parts that are less fun than the demo.
Meet the owner in WhatsApp, keep the rigour identical to the app, and never accept a chat reply as a signature.
- WhatsApp Business Cloud API direct (or 360dialog), not an unofficial bridge that gets your number banned.
- Webhooks are routes, not subdomains:
/webhooks/whatsappbehind Cloudflare, on the same brain. - Briefs and alerts are background jobs on Redis, not request-path work.
- Answer in the language the question arrived in - English, Swahili or Sheng - and test that routing in CI.
- SMS fallback through the gateway we already run, because a brief conditional on data coverage is not a habit.
Use the official API, and budget for the template rules
The tempting shortcut is an unofficial library driving WhatsApp Web. Do not. It works in a demo and it gets business numbers banned, and the ban lands on the client's number rather than yours.
WhatsApp Business Cloud API direct is the cheapest official route; 360dialog is the reasonable alternative if you want a BSP handling onboarding. Either way there are two rules that shape the design more than the SDK does:
- The 24-hour service window. Free-form replies are only permitted within 24 hours of the user's last message. Outside it you must send an approved template.
- Templates are pre-approved and parameterised. You cannot compose arbitrary prose for a proactive message.
That second rule is the one that catches people, because the daily brief is by definition a proactive message. You cannot send a freshly-written paragraph of analysis at 7am. You send a template with parameters, and the interesting content has to fit the slots you registered.
In practice this pushed the brief into a tighter shape than we would have chosen ourselves - a few named figures and a short action list - and the tighter shape turned out to be better. The constraint improved the product, which is not the sentence I expected to write about template approval.
Webhooks are routes, not subdomains
A small piece of infrastructure hygiene that saves a certificate and a DNS record per integration.
api.rai.upeo.ai/webhooks/whatsapp
api.rai.upeo.ai/webhooks/mpesa
api.rai.upeo.ai/webhooks/paystack
One public host, behind Cloudflare for TLS, WAF and DDoS, terminating through Caddy to FastAPI. There is no reason for whatsapp.rai.upeo.ai to exist; it is a subdomain, a certificate and a firewall rule you now maintain forever in exchange for nothing.
The inbound handler does the minimum: verify the signature, acknowledge fast, enqueue. Meta retries on timeout, and an agent run is not something you want happening inside a webhook handler that has a few seconds of patience.
@router.post("/webhooks/whatsapp")
async def whatsapp_inbound(request: Request) -> Response:
body = await request.body()
verify_meta_signature(body, request.headers["X-Hub-Signature-256"])
for msg in parse_messages(body):
# Dedup on the provider message id - retries are guaranteed, not rare.
await queue.enqueue("handle_message", msg, job_id=f"wa:{msg.id}")
return Response(status_code=200)
The job_id is the whole idempotency story. Meta will redeliver, and a duplicated agent run is a duplicated charge against the owner's credits.
Briefs and alerts are background work
Two different things go out over the channel and they have different triggers, which means different infrastructure.
The brief is time-based. A scheduled job per tenant, running before the shop gets busy, computing the metrics, and sending the template. Redis plus RQ handles this; Celery if you prefer. The thing to get right is that the schedule is per tenant in their local time, and that a tenant whose connector is unreachable at 6am gets a retry rather than a silent gap.
Alerts are event-based. Threshold evaluations that run far more often than the brief and mostly send nothing. Days-of-stock crossing a floor, margin on a category dropping below a band, a supplier's unit price for a SKU jumping, a till mismatch.
Alerts need one thing the brief does not: hysteresis. A metric oscillating around a threshold will fire, clear and fire again, and three alerts about the same SKU in one morning is how you get muted. Fire on crossing, then suppress until the condition has cleared by a margin and stayed cleared.
I underestimated this. The alert logic is more code than the brief logic, and almost all of the extra is about not being annoying.
Language, and why it is a routing problem
An owner perfectly comfortable in English will still, given the choice, ask about their own money in Swahili or Sheng. It is not a competence issue - thinking about your business is informal, and formal language makes it feel like a task.
Technically this is one of the cheapest features in the product. Modern models handle Swahili and Sheng well, so the model side is close to free.
The part that is not free is that language sits upstream of the metric resolution. "Stock ya kaa gani inaisha" has to route to days_of_stock exactly as reliably as "what is about to run out". That is a correctness property, not a nicety, and it is exactly the kind of thing a prompt tweak breaks silently.
So the regression suite carries Swahili and Sheng phrasings alongside the English ones:
CASES = [
("what is about to run out", "days_of_stock"),
("stock ya kaa gani inaisha", "days_of_stock"),
("who owes me money", "receivables_aging"),
("nani ananidai", "receivables_aging"),
("ni nini haijauzwa kwa muda mrefu", "dead_stock"),
]
If you support a language and do not test it, you support it until the next deploy.
The boundary the casual channel does not move
This is where a lot of "AI on WhatsApp" products are quietly bad, so I want to be explicit.
The figures sent over WhatsApp come from the same metric catalog, with the same checks, as the ones in the app. There is no relaxed chat mode that guesses more freely because the format is informal.
And nothing that changes data happens through a chat reply. If Rai has drafted a purchase order, the message says it is ready and links to the approval screen. It does not accept "ndio" as a signature.
That is not caution for its own sake. A chat reply is a genuinely poor signature: trivially sent by accident, hard to attribute to a person rather than a handset, and impossible to bind to the specific proposal it was meant to approve when three are pending. Casual channel, formal boundary.
SMS fallback, which is not a nice-to-have
The brief only works if it always arrives. A brief that shows up on the days coverage is good is not a routine, and the routine was the entire objective.
So there is SMS fallback through the UPEO SMS Gateway we already run. It carries a fraction of the content - a few figures and a line - because that is what fits, and that is fine. The job of the fallback is not to replicate the brief. It is to make sure the owner's morning check never comes back empty, because the habit is more fragile than the feature.
Frequently asked questions
- Why not drive WhatsApp Web with an unofficial library?
- It works in a demo and it gets numbers banned, and the ban lands on your client's business number rather than on you. The Cloud API direct is the cheapest official route, and a BSP like 360dialog is the reasonable alternative if you want onboarding handled.
- How does the 24-hour window affect a scheduled daily brief?
- It forces the brief to be a pre-approved template with parameter slots rather than freshly written prose. That sounds like a limitation and it pushed us toward a tighter, more scannable brief than we would have chosen - which turned out to be better.
- How do you stop webhook retries double-charging a tenant?
- Enqueue with a job id derived from the provider message id. Meta retries are guaranteed rather than rare, so deduplication has to be structural. Without it, a retry is a second agent run and a second debit against the owner's credits.
- Why do alerts need hysteresis?
- Because a metric oscillating around a threshold fires, clears and fires again, and three alerts about one SKU in a morning is how you get muted. Fire on crossing, then suppress until the condition has cleared by a margin and stayed cleared. Most of the alert code is about not being annoying.