WhatsApp Business
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-10-01 against the WhatsApp Business Platform Cloud API documentation published by Meta (its examples use Graph API v25.0 on that date), FastAPI 0.142.2 with the LangChain 1.4.3 core, Spring Boot 4.1.1 with the Spring AI 2.0.1 core, and twilio 9.11.2 for Python (see the version baseline). The snippets are illustrative: they were written from the official Meta and Twilio documentation and are not compiled or run by this repository. Meta changes the Graph API version, the documentation URLs and the pricing and policy rules frequently, so treat every identifier, URL and number here as something to re-check against the live documentation before you build on it. The text of the WhatsApp Business Solution Terms could not be re-read programmatically while this page was prepared; the policy section below therefore points at the terms rather than quoting them.
WhatsApp is the largest of the business channels in this sub-section and also the most governed one: you do not build a bot on top of a user’s account, you register a business phone number with Meta, receive every inbound message as a webhook call, and reply through an HTTPS API under rules about conversation windows, templates, opt-in, pricing and, since 2026, the kind of AI product you may offer. The assistant core is the same one described in Channel-Agnostic Architecture; this page covers the adapter. WhatsApp has no token streaming and no message-edit API for business messages, so the adapter sends a short typing indicator and one complete answer (or a few chunked messages), and it renders citations as text and, where useful, as buttons.
The shape of the integration
Three properties drive the whole design. Inbound traffic is a webhook, so the adapter is a public HTTPS endpoint that must authenticate Meta and must answer fast. Outbound traffic is a plain REST call authenticated with a bearer token, so the adapter owns a long-lived credential. And the conversation is governed by a 24-hour window that decides whether you may send free-form text or only a pre-approved template.
Setting up the Cloud API
The Cloud API is the hosted version of the WhatsApp Business Platform: Meta runs the infrastructure, you call the Graph API. (The older on-premises API has been retired in favour of it; check the current documentation for its status if you inherit an old integration.) The objects you create, in order, are below.
| Object | What it is and what you need from it |
|---|---|
Meta developer account and business portfolio |
The developer account owns the app. A Meta business portfolio (formerly Business Manager) owns the business assets and carries the business verification that unlocks higher messaging limits. |
App |
A Meta app of the business type with the WhatsApp product added. Its app secret is what signs webhook payloads, and its app ID identifies it in the dashboard. The secret never leaves your server. |
WhatsApp Business Account (WABA) |
The container for your phone numbers, message templates and billing. Webhook subscriptions are made per WABA. |
Business phone number |
A number you own and register on the WABA; it must not be in use on a personal WhatsApp account. The API addresses it by its phone number ID, which is not the phone number itself. A free test number with a small recipient allow-list is provided for development. |
Access token |
Calls to the Graph API carry |
The practical consequences for the adapter are about configuration. It needs five values, all supplied from the environment and never committed: the phone number ID, the access token, the app secret, a verify token that you invent, and the Graph API version. Store them in a secrets manager (for Azure, see Key Vault and Secrets) and rotate the system-user token on a schedule. The verify token is only a shared string used once during subscription; it is not a credential for the API, but it should still be random and kept out of source control.
# Obvious placeholders -- load the real values from your secrets manager
export WHATSAPP_PHONE_NUMBER_ID="<phone-number-id>"
export WHATSAPP_ACCESS_TOKEN="<system-user-access-token>"
export WHATSAPP_APP_SECRET="<app-secret>"
export WHATSAPP_VERIFY_TOKEN="<random-string-you-choose>"
export WHATSAPP_GRAPH_VERSION="v25.0"
Webhooks: verification and signature checks
You give Meta one callback URL in the app dashboard and subscribe the WABA to the messages webhook field. Two kinds of
request then arrive at that URL.
Verification (GET). When you save the callback URL, Meta sends a GET request with the query parameters hub.mode
(subscribe), hub.verify_token (the string you typed in the dashboard) and hub.challenge (a random string). If the
token matches your configured value, the endpoint answers 200 with the challenge echoed back as the body; otherwise it
answers with a client error. This proves that you control the URL; it proves nothing about who calls it later.
Notifications (POST). Every event is a JSON POST carrying an X-Hub-Signature-256 header of the form
sha256=<hex digest>. The digest is an HMAC-SHA256 of the raw request body keyed with the app secret. The adapter must
compute the same digest over the exact bytes it received and compare in constant time; parsing the JSON first and
re-serializing it will change the bytes and break the check. Anyone who knows your URL can otherwise forge a message and
make the assistant answer on behalf of an arbitrary "user", so this check is not optional. The endpoint should also return
200 quickly: Meta retries failed deliveries with decreasing frequency for up to seven days, payloads can batch several
updates, and the same message can therefore arrive more than once. Deduplicate on the WhatsApp message ID (the wamid…
string) and push slow work to a background task, as described in
Channel-Agnostic Architecture.
The notification body has a fixed envelope. An abbreviated inbound text message looks like this (identifiers are placeholders):
{
"object": "whatsapp_business_account",
"entry": [{
"id": "<waba-id>",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "<display-number>", "phone_number_id": "<phone-number-id>" },
"contacts": [{ "profile": { "name": "Ada" }, "wa_id": "<sender-wa-id>" }],
"messages": [{
"from": "<sender-wa-id>",
"id": "wamid.<message-id>",
"timestamp": "1790000000",
"type": "text",
"text": { "body": "How do I rotate the API key?" }
}]
}
}]
}]
}
Source: WhatsApp webhooks overview
The same value object carries a statuses array instead of messages when the event is a delivery receipt (sent,
delivered, read, failed); the adapter should ignore those for answering but log failures. Other message types (image,
audio, location, interactive replies, button) arrive in the same array with a different type, so the adapter
must branch on type and politely decline what it does not support rather than assuming text.
Identity and conversation mapping
The sender’s wa_id (in practice the phone number in international format) is the natural userId: WhatsApp has already
verified that the person controls it. The conversationId can be the pair of the wa_id and your phone_number_id
because a WhatsApp chat between a user and a business number is a single continuous thread; there are no threads inside
it. A consequence worth planning for is that a very long single thread is also a very long memory, so the core should
summarize or truncate as in Conversational RAG & Memory. The
access-control rules from RAG Access Control & Privacy apply to this
userId; if the assistant serves customers, map wa_id to a customer record (for example after a one-time linking flow)
before you let the retriever see anything beyond public documents. Phone numbers are personal data: do not put them in
logs or traces in clear text.
Sending messages
Every outbound message is a POST to https://graph.facebook.com/<version>/<phone-number-id>/messages with the bearer token
and a JSON body that always contains "messaging_product": "whatsapp". A plain text reply to a user who wrote to you in
the last 24 hours looks like this:
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "<sender-wa-id>",
"type": "text",
"text": { "preview_url": false, "body": "Rotate it under Settings > API keys. Sources: [1] Admin guide." }
}
Adding "context": { "message_id": "wamid.<message-id>" } makes the reply appear as a quote of the user’s message, which
helps when several answers are in flight. A successful call returns the new message’s ID, which you can correlate with the
later status webhooks. The API also offers separate calls to mark an inbound message as read and to show a typing
indicator; the adapter should use them as the equivalent of the typing state that XMPP gives you (see
XMPP Bots), because retrieval plus generation can take several seconds.
Text bodies are limited in length (currently a few thousand characters); the adapter should split long answers on paragraph
boundaries and keep the first message self-sufficient, since there is no streaming to soften a long wait.
Source: Send messages
The 24-hour window and templates
When a user messages your number, a 24-hour customer service window opens (and is renewed by each new user message). Inside it you can send free-form messages of any supported type, which is everything the DocsAssistant normally does. Outside it, only a template message is allowed: a message whose structure, language and category were submitted to Meta and approved in advance, with numbered or named placeholders filled at send time. Categories are utility (transactional updates), authentication (one-time codes) and marketing (promotions); the category influences price, review strictness and whether the user has to have opted in to that kind of message. Review is automatic and usually quick but can take up to a day, and an approved template can later be paused or disabled if recipients block or report it.
A template send names the template and fills its components:
{
"messaging_product": "whatsapp",
"to": "<recipient-wa-id>",
"type": "template",
"template": {
"name": "docs_followup",
"language": { "code": "en_US" },
"components": [{
"type": "body",
"parameters": [{ "type": "text", "text": "Ada" }]
}]
}
}
Source: Message templates overview
For an assistant the window has two design consequences. First, an answer that takes longer than the window to arrive (for example after a human handoff, see Human in the Loop) cannot be delivered as free text and needs a template, so keep a small approved "your answer is ready, reply to see it" template for that case, and reply to it to reopen the window. Second, proactive messages (reminders, release announcements) are always templates and are subject to opt-in and to Meta’s marketing rules, so do not use the assistant as a broadcast tool.
Interactive messages
WhatsApp supports a small set of structured messages that map well to RAG answers. Reply buttons offer up to three short
choices, list messages offer a menu of selectable rows grouped in sections, and call-to-action URL buttons open a link.
When the user taps a button or a row, the webhook delivers an interactive message whose button_reply or list_reply
contains the id and title you assigned, which is an easy way to implement "show source", "this helped" and "talk to a
person" without natural-language parsing. Button reply titles and list row titles have tight length limits, so use short
verbs and keep the real payload in the id.
{
"messaging_product": "whatsapp",
"to": "<sender-wa-id>",
"type": "interactive",
"interactive": {
"type": "button",
"body": { "text": "Did this answer your question?" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "fb:yes:<turn-id>", "title": "Yes" } },
{ "type": "reply", "reply": { "id": "fb:no:<turn-id>", "title": "No" } },
{ "type": "reply", "reply": { "id": "handoff:<turn-id>", "title": "Talk to a person" } }
]
}
}
}
Source: Send messages
Citations have no card format on WhatsApp. The pragmatic rendering is numbered markers in the text plus a final line of short URLs (WhatsApp makes them tappable), or a list message whose rows are the sources. The layout choices and the feedback loop are discussed in Citations & Grounding and Agent UX. WhatsApp also renders a limited text markup (asterisks for bold, underscores for italics, backticks for monospace), which is not Markdown; convert the core’s Markdown output rather than sending it as is.
Pricing
Since 2025-07-01 the platform charges per delivered template message according to its category and the recipient’s country, while free-form messages inside an open customer service window are not charged per message. Rates, free-entry-point rules and volume tiers change often and differ by market, so this page deliberately gives no numbers: read the official pricing page and its rate cards when you size a deployment. For capacity planning the useful quantity is the number of template messages per month, \( N_{\text{tmpl}} \), times the per-message rate of each category and country; an assistant that only answers inside the window has \( N_{\text{tmpl}} \approx 0 \) apart from re-engagement templates, and its main variable cost is the model, not the channel. A separate quality rating and messaging-limit system governs how many distinct users you may start conversations with each day and is raised by business verification and good user feedback.
The AI-provider policy
WhatsApp restricts what kind of AI product may run on its business API, and that restriction is the first thing to check before you plan a deployment. The authoritative text is the WhatsApp Business Solution Terms (the address redirects to the current Meta-hosted page). The terms were updated with an AI-related clause that Meta announced would apply to all accounts from 2026-01-15. The text of that clause could not be re-read while this page was written, so what follows is the commonly reported reading and not a quotation: general-purpose AI assistants, that is chatbots whose primary function is to be an open-ended assistant or large-language-model service offered to the public, are not permitted on the Business Solution, while AI used by a business for its own customer interactions, such as support, sales assistance or answering questions over its own knowledge, remains allowed. Read the current terms yourself, with your legal or compliance contact, and re-read them whenever you ship a material change to the assistant, because the wording, the examples and the enforcement practice are Meta’s to change.
How the DocsAssistant pattern sits against that reading is a design question you can influence:
| Deployment | Likely reading | Design guidance |
|---|---|---|
A RAG assistant over one company’s documentation, products or policies, answering customers or employees of that company |
Business-scoped use of AI; the case the policy is generally reported to leave open |
Keep the scope narrow in the system prompt and in the retrieval corpus, refuse out-of-scope requests, and say what the bot is for in the welcome message. |
A bot whose job is to talk about anything: a free-form chat companion, a general question-answering or coding assistant, or a reseller of a model’s chat product |
General-purpose assistant as the primary function; the case the policy is reported to restrict |
Do not deploy on the WhatsApp Business API. Use a channel without this rule, such as the web chat in Streaming Chat APIs or the other adapters of this sub-section. |
A business assistant that sometimes answers general questions as a courtesy |
Unclear; depends on how central the open-ended behavior is |
Restrict it with an out-of-scope refusal path and ask for a review of the terms; the risk grows with how much of the traffic is open-ended. |
Two implementation habits reduce the risk regardless of the exact wording. Identify the bot as automated in the profile and first message and offer a path to a human (see Human in the Loop), and keep an audit trail of what the bot is configured to do. Policy, privacy and data-processing questions for the model provider that sits behind the core are separate and are covered in RAG Access Control & Privacy.
The Python adapter: FastAPI over the LangChain core
The webhook is one FastAPI app with a GET route for the handshake and a POST route for events. The handler reads the raw
body for the signature check, returns 200 straight away and schedules the answer as a background task. ask is the async
generator of the LangChain core (create_agent, retriever tool, Postgres checkpointer) described in
Channel-Agnostic Architecture; for the underlying
framework see Agents with create_agent and
Short-Term Memory & Checkpointers. Deployment of the app behind
HTTPS is covered in Serving RAG as an API.
import hashlib
import hmac
import os
import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
from docs_assistant.core import ask # async generator of {"type": "token"|"citation"|"done", ...}
PHONE_NUMBER_ID = os.environ["WHATSAPP_PHONE_NUMBER_ID"]
ACCESS_TOKEN = os.environ["WHATSAPP_ACCESS_TOKEN"]
APP_SECRET = os.environ["WHATSAPP_APP_SECRET"].encode()
VERIFY_TOKEN = os.environ["WHATSAPP_VERIFY_TOKEN"]
GRAPH = f"https://graph.facebook.com/{os.environ.get('WHATSAPP_GRAPH_VERSION', 'v25.0')}"
app = FastAPI()
seen: set[str] = set() # illustrative; use Redis or a database with a TTL in production
@app.get("/webhook")
async def verify(
mode: str = Query(alias="hub.mode"),
token: str = Query(alias="hub.verify_token"),
challenge: str = Query(alias="hub.challenge"),
) -> Response:
if mode == "subscribe" and hmac.compare_digest(token, VERIFY_TOKEN):
return Response(content=challenge, media_type="text/plain")
raise HTTPException(status_code=403)
def signature_ok(body: bytes, header: str | None) -> bool:
if not header or not header.startswith("sha256="):
return False
expected = hmac.new(APP_SECRET, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header.removeprefix("sha256="))
@app.post("/webhook")
async def receive(request: Request, background: BackgroundTasks) -> Response:
body = await request.body() # raw bytes: verify before parsing
if not signature_ok(body, request.headers.get("x-hub-signature-256")):
raise HTTPException(status_code=401)
payload = await request.json()
for entry in payload.get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
for message in value.get("messages", []):
if message["id"] in seen:
continue
seen.add(message["id"])
background.add_task(handle_message, message)
return Response(status_code=200)
async def send(payload: dict) -> None:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
f"{GRAPH}/{PHONE_NUMBER_ID}/messages",
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
json={"messaging_product": "whatsapp", **payload},
)
response.raise_for_status()
async def record_feedback(turn_id: str, helpful: bool) -> None:
... # placeholder: store the vote against the turn in your feedback table
async def start_handoff(sender: str, turn_id: str) -> None:
... # placeholder: hand the conversation to a human queue (see Human in the Loop)
async def handle_message(message: dict) -> None:
sender = message["from"]
if message["type"] == "interactive":
interactive = message["interactive"]
reply = interactive.get("button_reply") or interactive.get("list_reply") or {}
reply_id = reply.get("id", "")
if reply_id.startswith("fb:"): # fb:yes:<turn-id> or fb:no:<turn-id>
_, vote, turn_id = reply_id.split(":", 2)
await record_feedback(turn_id, vote == "yes")
return
if reply_id.startswith("handoff:"): # handoff:<turn-id>
await start_handoff(sender, reply_id.removeprefix("handoff:"))
return
if message["type"] != "text":
await send({"to": sender, "type": "text",
"text": {"body": "I can only read text messages for now."}})
return
user_id = sender
conversation_id = f"{sender}|{PHONE_NUMBER_ID}"
text, citations = "", []
async for event in ask(conversation_id, user_id, message["text"]["body"]):
if event["type"] == "token":
text += event["text"]
elif event["type"] == "citation":
citations.append(event)
sources = "\n".join(f"[{c['marker']}] {c['title']} {c['url']}" for c in citations)
body = f"{text}\n\n{sources}" if sources else text
await send({
"to": sender,
"type": "text",
"context": {"message_id": message["id"]},
"text": {"preview_url": False, "body": body[:4000]},
})
Source: Create a webhook endpoint and FastAPI background tasks
Several details are deliberate. The GET handler uses the dotted query names through Query(alias=…) because FastAPI
cannot declare a parameter called hub.mode otherwise. The signature is checked with hmac.compare_digest over the raw
bytes before any JSON is parsed. The in-memory seen set makes the example short but is wrong for more than one worker
or for restarts; use a shared store with an expiry slightly longer than Meta’s retry horizon, or make the handler
idempotent by keying the checkpoint on the message ID. FastAPI’s background tasks run in the same process after the
response, which is fine for a few seconds of generation but loses work if the process dies; for slow or critical answers
use the queue-and-worker variant from the architecture page. Finally, nothing here prevents two concurrent messages from the same
user from running at once; serialize per conversation_id, because the checkpointer does not order concurrent writes for
you.
The Java adapter: Spring Boot over the Spring AI core
The Spring adapter has the same three parts: a controller with the handshake and the notification endpoints, a signature
check over the raw body, and a client that calls the Graph API. The AssistantCore.ask method returns a Flux<CoreEvent>
as in the architecture page; the adapter collects it into one message with reduce, because WhatsApp cannot show partial
text. See ChatModel and ChatClient,
Chat Memory Repositories and
Production and Deployment for the core, and
REST APIs for the controller basics.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@RestController
@RequestMapping("/webhook")
class WhatsAppWebhook {
private final AssistantCore core;
private final ObjectMapper mapper;
private final RestClient graph;
private final String phoneNumberId;
private final byte[] appSecret;
private final String verifyToken;
private final Map<String, Boolean> seen = new ConcurrentHashMap<>(); // illustrative; use a shared store
WhatsAppWebhook(AssistantCore core, ObjectMapper mapper, RestClient.Builder builder,
@Value("${whatsapp.phone-number-id}") String phoneNumberId,
@Value("${whatsapp.access-token}") String accessToken,
@Value("${whatsapp.app-secret}") String appSecret,
@Value("${whatsapp.verify-token}") String verifyToken,
@Value("${whatsapp.graph-version:v25.0}") String version) {
this.core = core;
this.mapper = mapper;
this.phoneNumberId = phoneNumberId;
this.appSecret = appSecret.getBytes(StandardCharsets.UTF_8);
this.verifyToken = verifyToken;
this.graph = builder.baseUrl("https://graph.facebook.com/" + version)
.defaultHeader("Authorization", "Bearer " + accessToken)
.build();
}
@GetMapping
ResponseEntity<String> verify(@RequestParam("hub.mode") String mode,
@RequestParam("hub.verify_token") String token,
@RequestParam("hub.challenge") String challenge) {
boolean ok = "subscribe".equals(mode)
&& MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
verifyToken.getBytes(StandardCharsets.UTF_8));
return ok ? ResponseEntity.ok(challenge) : ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@PostMapping
ResponseEntity<Void> receive(@RequestBody byte[] body,
@RequestHeader(value = "X-Hub-Signature-256", required = false) String signature)
throws Exception {
if (!signatureOk(body, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
JsonNode root = mapper.readTree(body);
for (JsonNode entry : root.path("entry")) {
for (JsonNode change : entry.path("changes")) {
for (JsonNode message : change.path("value").path("messages")) {
if (seen.putIfAbsent(message.path("id").asText(), true) == null) {
handle(message); // subscribes on another scheduler; returns immediately
}
}
}
}
return ResponseEntity.ok().build();
}
private boolean signatureOk(byte[] body, String header) throws Exception {
if (header == null || !header.startsWith("sha256=")) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret, "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(body));
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
header.substring("sha256=".length()).getBytes(StandardCharsets.UTF_8));
}
private void handle(JsonNode message) {
String sender = message.path("from").asText();
String type = message.path("type").asText();
if ("interactive".equals(type)) {
JsonNode interactive = message.path("interactive");
JsonNode reply = interactive.has("button_reply")
? interactive.path("button_reply") : interactive.path("list_reply");
String replyId = reply.path("id").asText();
if (replyId.startsWith("fb:")) { // fb:yes:<turn-id> or fb:no:<turn-id>
String[] parts = replyId.split(":", 3);
async(() -> recordFeedback(parts[2], "yes".equals(parts[1])));
return;
}
if (replyId.startsWith("handoff:")) { // handoff:<turn-id>
async(() -> startHandoff(sender, replyId.substring("handoff:".length())));
return;
}
}
if (!"text".equals(type)) {
async(() -> send(Map.of("to", sender, "type", "text",
"text", Map.of("body", "I can only read text messages for now."))));
return;
}
core.ask(phoneNumberId + ":" + sender, sender, message.path("text").path("body").asText())
.reduce(new StringBuilder(), (sb, event) -> switch (event) {
case CoreEvent.Token t -> sb.append(t.text());
case CoreEvent.Citation c -> sb.append("\n[").append(c.marker()).append("] ")
.append(c.title()).append(' ').append(c.url());
case CoreEvent.Done d -> sb;
})
.subscribeOn(Schedulers.boundedElastic())
.subscribe(answer -> send(Map.of(
"to", sender, "type", "text",
"context", Map.of("message_id", message.path("id").asText()),
"text", Map.of("preview_url", false, "body", truncate(answer.toString())))));
}
private void async(Runnable task) {
Mono.fromRunnable(task).subscribeOn(Schedulers.boundedElastic()).subscribe();
}
private void recordFeedback(String turnId, boolean helpful) {
// placeholder: store the vote against the turn in your feedback table
}
private void startHandoff(String sender, String turnId) {
// placeholder: hand the conversation to a human queue (see Human in the Loop)
}
private void send(Map<String, Object> payload) {
var body = new java.util.HashMap<String, Object>(payload);
body.put("messaging_product", "whatsapp");
graph.post().uri("/{id}/messages", phoneNumberId).body(body).retrieve().toBodilessEntity();
}
private static String truncate(String s) {
return s.length() <= 4000 ? s : s.substring(0, 4000);
}
}
Because citations in the core are emitted after or among the tokens, the reducer simply appends them as text lines; a
production adapter would instead collect tokens and citations separately and format the final message once. The @RequestBody
byte[] parameter is what gives the signature check the unmodified bytes: if you bind to a JSON type first, Spring
re-serializes and the HMAC no longer matches. The property names in @Value are placeholders for Spring configuration that
is itself fed from environment variables or a secrets store; do not hard-code them in application.yml. Everything said
about deduplication, per-conversation serialization and queues in the Python section applies unchanged, and the observability
hooks of Observability cover tracing a turn across the webhook and the model call.
Twilio WhatsApp as an alternative
Twilio is one of the Business Solution Providers that resell WhatsApp access. You keep the same assistant core but replace the Meta app, WABA and token plumbing with a Twilio account: Twilio registers the sender, hosts the webhook contract and bills you for both its own fee and the WhatsApp charges. For development it offers a sandbox number that any WhatsApp user can join by sending a keyword, which is the quickest way to try an adapter without a business verification. The main differences from the direct Cloud API are listed below.
| Aspect | Meta Cloud API | Twilio WhatsApp |
|---|---|---|
Inbound webhook |
JSON, signed with |
Form-encoded ( |
Outbound |
|
Twilio Messages API with |
Identity |
|
|
Templates and window |
Same rules; you manage templates in the WABA |
Same WhatsApp rules; templates are managed through Twilio and referenced by a content SID |
Cost |
Meta’s per-message rates, billed by Meta |
Twilio’s fee on top of Meta’s rates |
Best fit |
Direct control, newest platform features first |
Existing Twilio estate, one vendor for SMS, voice and WhatsApp, a ready sandbox |
The Twilio adapter in FastAPI is shorter because the SDK validates the signature and sends the reply:
import os
from fastapi import BackgroundTasks, FastAPI, Form, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from twilio.request_validator import RequestValidator
from twilio.rest import Client
from docs_assistant.core import ask
AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"] # placeholder: load from your secrets manager
twilio = Client(os.environ["TWILIO_ACCOUNT_SID"], AUTH_TOKEN)
validator = RequestValidator(AUTH_TOKEN)
FROM = os.environ["TWILIO_WHATSAPP_FROM"] # e.g. "whatsapp:+<your-sender-number>"
app = FastAPI()
async def answer(sender: str, body: str) -> None:
text = ""
async for event in ask(sender, sender, body):
if event["type"] == "token":
text += event["text"]
# the Twilio client is synchronous: keep it off the event loop
await run_in_threadpool(twilio.messages.create, from_=FROM, to=sender, body=text[:1500])
@app.post("/twilio/whatsapp")
async def inbound(request: Request, background: BackgroundTasks,
From: str = Form(), Body: str = Form()) -> Response:
form = dict(await request.form())
url = os.environ["PUBLIC_WEBHOOK_URL"] # the exact URL configured in Twilio
if not validator.validate(url, form, request.headers.get("X-Twilio-Signature", "")):
raise HTTPException(status_code=403)
background.add_task(answer, From, Body)
# empty TwiML right away; the answer is sent later through the Messages API
return Response(content="<Response/>", media_type="application/xml")
Source: Twilio WhatsApp and Twilio webhook security
Twilio waits at most 15 seconds for the webhook response and otherwise logs error 11200, which a full generation can
easily exceed, so the handler only validates the request, schedules the answer as a background task and returns an empty
TwiML document at once. The Twilio client is synchronous, so the background task calls it through run_in_threadpool
to keep the event loop free (a durable worker is the production equivalent), and note that Twilio imposes its own shorter
message length limit. Using a provider does not remove the Meta policies: the Business
Solution Terms, the AI-provider rules and the opt-in requirements still apply to your use, and the provider’s own terms
apply on top.
Opt-in and compliance
Meta requires that a person has opted in to receive messages from your business on WhatsApp before you message them outside a conversation they started. For an inbound-only assistant the user’s message is the opt-in for the answer, but anything proactive needs a recorded consent that names the business and the kind of messages, collected on a form, in person, on the web or inside another channel. Keep the consent record (who, when, what wording, which number), honour opt-outs immediately (a keyword such as STOP should switch off proactive messages, and the assistant must not reply to it with more marketing), and expect Meta to rate-limit or restrict numbers that users block or report. The remaining obligations are ordinary data-protection ones, and they apply with extra force because the phone number is the identifier:
-
Tell users that they are talking to an automated assistant and where to find the privacy notice, and offer a human handoff.
-
Decide the retention of conversation text, the checkpointer and the traces; pseudonymize the number in logs and do not send it to the model provider unless the answer needs it.
-
If you operate in the EU or UK, record the lawful basis, handle access and deletion requests against the checkpoint and memory stores, and check the data-transfer terms of the model provider and of Meta.
-
Keep the app secret and the system-user token in a secrets manager, rotate them, and revoke the token if a host is compromised; rotate the webhook verify token only when you re-subscribe.
The signature, retry, deduplication and secret-handling practices for all channels are collected in Operating Chat Channels, and the one-page summary is in the Cheat Sheet (PDF).
Summary
| Topic | Rule of thumb |
|---|---|
Setup |
App with the WhatsApp product, a WABA, a registered phone number, and a system-user token; configuration comes from the environment. |
Inbound |
Verify the GET handshake with the verify token, verify every POST with HMAC-SHA256 over the raw body, answer 200 fast, deduplicate on the message ID. |
Outbound |
|
Window |
Free text only within 24 hours of the user’s last message; otherwise an approved template. |
Interactive |
Up to three reply buttons or a list menu; use ids that carry the action; citations as text lines. |
Pricing |
Per template message by category and country; see the official page, never hard-code rates. |
Policy |
Business-scoped RAG assistants versus general-purpose assistants: read the current Business Solution Terms. |
Alternative |
Twilio WhatsApp with a sandbox, different signature scheme and extra fee. |
References
-
WhatsApp Business Platform, get started: https://developers.facebook.com/documentation/business-messaging/whatsapp/get-started
-
Webhooks overview: https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview/
-
Create a webhook endpoint: https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/create-webhook-endpoint
-
Send messages: https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages
-
Message templates: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview
-
Pricing: https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing
-
WhatsApp Business Solution Terms: https://www.whatsapp.com/legal/business-solution-terms
-
Twilio WhatsApp: https://www.twilio.com/docs/whatsapp
-
Twilio webhook security: https://www.twilio.com/docs/usage/security
-
FastAPI background tasks: https://fastapi.tiangolo.com/tutorial/background-tasks/
-
Spring Framework REST clients: https://docs.spring.io/spring-framework/reference/integration/rest-clients.html