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

sequenceDiagram autonumber participant U as "WhatsApp user" participant M as "Meta (Cloud API)" participant A as "Webhook adapter" participant C as "DocsAssistant core" U->>M: "How do I rotate the API key?" M->>A: "POST /webhook (JSON, X-Hub-Signature-256)" A->>A: "verify signature, dedupe by message id" A-->>M: "200 OK (immediately)" A->>C: "ask(conversationId, userId, text)" C-->>A: "tokens, citations, done" A->>M: "POST /PHONE_NUMBER_ID/messages" M->>U: "Answer with sources"

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 Authorization: Bearer <token>. The temporary token in the dashboard expires within a day or so and is for first experiments only. Production uses a system user of the business portfolio with a token granted the whatsapp_business_messaging permission (and whatsapp_business_management if the adapter also manages templates).

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?" }
        }]
      }
    }]
  }]
}

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" }]
    }]
  }
}

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]},
    })

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 X-Hub-Signature-256 and the app secret

Form-encoded (From, To, Body, MessageSid, …​), signed with X-Twilio-Signature and your auth token over the URL and parameters

Outbound

POST /{phone-number-id}/messages with a JSON body and a bearer token

Twilio Messages API with from_="whatsapp:+<sender>" and to="whatsapp:+<recipient>", or a TwiML response

Identity

wa_id in the payload

From field prefixed with whatsapp:

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")

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

POST /{phone-number-id}/messages with messaging_product: whatsapp; no streaming, so send one complete message or a few chunks.

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.