Two billion people use WhatsApp every day. The Cloud API is not a chat socket. It is a constrained messaging protocol: session states, pre-approved templates, and per-message billing. If you skip those rules, messages fail silently or the account gets flagged.
โโโ โโโโโโ โโโ โโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโ โโโโโโโ โโโโโโโ โโโ โโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโ โโ โโโโโโโโโโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโ โโโโโโโโโโโโโ โโโโโโ โโโ โโโ โโโโโโโโโโโ โโโโโโ โโโ โโโโโโโโ โโโ โโโโโโ โโโ โโโ โโโโโโโโโโโ โโโโโโ โโโ โโโโโโโ โโโ โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโ โโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
WhatsApp is not an SMS replacement with a green icon. The Meta Cloud API is a regulated messaging channel with hard protocol rules: a 24-hour session window that gates every message type, a three-category template system that must pass editorial review before any message can be initiated, a quality score that gates your ability to scale, and per-message billing that changed in July 2025. Miss any of these and your messages either fail silently or your account gets flagged.
The WhatsApp Cloud API is an asymmetric duplex channel. You send outbound via REST (POST /messages). Inbound messages and delivery events arrive via HTTPS webhooks Meta pushes to you. What you can send outbound depends entirely on whether a user-initiated session is open, that asymmetry is the entire architecture constraint you're building around.
POST to graph.facebook.com/v23.0/{PHONE_NUMBER_ID}/messages with a Bearer system-user token.X-Hub-Signature-256, return 200 immediately, and process asynchronously. Synchronous handling causes retry floods.WhatsApp Cloud API is not a standalone product, it is a specialized surface over graph.facebook.com. The same Graph versioning rules, error model, token taxonomy, and rate limit headers apply. Understanding the Graph once means understanding every Meta product. The WhatsApp-specific pieces are the phone number node, the WABA (WhatsApp Business Account) node, and the /messages edge.
Meta App (App ID + App Secret)
โ
โโโ WhatsApp Business Account (WABA node)
โ โโโ Phone Number 1 (PHONE_NUMBER_ID)
โ โ โโโ POST /messages โ outbound send
โ โโโ Phone Number 2
โ โโโ Message Templates (per WABA, per language)
โ โโโ Webhook Subscription (WABA-level events)
โ
โโโ System User (production credential)
โ โโโ System User Access Token (long-lived, non-expiring)
โ
โโโ App Dashboard
โโโ App Secret (webhook signature key)
โโโ App Access Token (app-level ops)
Token type determines what you can do and for how long. The correct production credential is a system user access token, not a user access token, not a page token. Regular user tokens expire in ~24 hours, which is a silent footgun for production servers. System user tokens are long-lived, business-scoped, and can be configured to never expire.
| Token Type | Lifetime | Use |
|---|---|---|
| User access token (short) | 1โ2 hours | Client-side reads after login |
| User access token (long) | ~60 days | Server calls on behalf of a person |
| System user token | Configurable, non-expiring | โ Production server automation |
| Business integration token | Long | Multi-tenant SaaS (Tech Provider, Embedded Signup) |
Current stable Graph version: v23.0. Unversioned calls default to the oldest supported version, a silent footgun. Pin the version in code. Graph versions are supported for roughly two years; track the changelog to plan upgrades. All examples in this article use https://graph.facebook.com/v23.0/....
The session window is the central constraint of the entire platform. It is not a rate limit, it is a policy gate that determines which message types you are allowed to send. Every architectural decision flows from it.
USER SENDS MESSAGE
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Customer-Service Window OPEN โ
โ Duration: 24 hours from last user message โ
โ โ
โ โ text, image, video, audio, document โ
โ โ interactive (buttons, lists) โ
โ โ location, reaction, contacts โ
โ โ UTILITY templates (free) โ
โ โ MARKETING templates (billed) โ
โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 24 hours pass
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Window CLOSED โ
โ โ
โ โ free-form text, silently dropped โ
โ โ interactive messages, silently dropped โ
โ โ
โ โ APPROVED template messages ONLY โ
โ (MARKETING, UTILITY billed, AUTH billed) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
EXCEPTION: Click-to-WhatsApp (CTWA) ads open a 72-hour window, not 24 h.
The session window means WhatsApp does not behave like email or SMS. You cannot cold-message users whenever you want. You must either:
The practical implication: your chatbot's free-form replies only work while a session is open. Outside the window, the bot must initiate with a template or wait. Build your state machine around this from the start.
Attempting to send a free-form message outside a session window, or sending a PENDING or REJECTED template, does not return an error in the way you expect. The API returns a 200 with a wamid, but the message is never delivered. You only discover this through the delivery status webhook (status: failed) or by watching the database and noticing messages that never reached delivered. Build explicit delivery tracking from day one.
Templates are the only message type that can cross session boundaries. They are pre-approved blueprints stored in your WABA, reviewed by Meta before use, and billed based on category and delivery. Getting the category right at submission time is critical, Meta re-categorizes templates and bills accordingly, regardless of what you submitted.
POST /v23.0/{WABA_ID}/message_templates with name, language, category, and components. Include realistic example values, reviewers use them. Status becomes PENDING.GET /{WABA_ID}/message_templates or listen for the message_template_status_update webhook event.APPROVED. Templates approved per language code, en and en_US are separate approvals. Send using type: "template" in the messages payload with component parameters substituted at send time.PAUSED (rate limited) or DISABLED if quality drops. A UTILITY template submitted with marketing content gets recategorized to MARKETING and billed accordingly.Parameters are substituted at send time into the approved template body. The API version is pinned in the URL. The messaging_product field is always "whatsapp".
POST https://graph.facebook.com/v23.0/{PHONE_NUMBER_ID}/messages
Authorization: Bearer {SYSTEM_USER_TOKEN}
Content-Type: application/json
{
"messaging_product": "whatsapp",
"to": "254712345678", // E.164, no leading +
"type": "template",
"template": {
"name": "order_update",
"language": { "code": "en_US" },
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Amani" },
{ "type": "text", "text": "#4821" },
{ "type": "text", "text": "out for delivery" }
]
}
]
}
}
The to field takes E.164 format without a leading +, e.g. 254712345678 for Kenya. Twilio's WhatsApp integration additionally requires the whatsapp:+ prefix and a leading +. Daraja (M-Pesa) also uses the 254... format but normalize explicitly per API, never reuse a string built for one system in another.
Every inbound message, every delivery event (sent, delivered, read, failed), and every template status change arrives via webhook. The webhook is your only source of truth for what actually happened. Getting the security and processing model right protects you from both injection attacks and retry cascades.
META SERVERS
โ
โ POST /webhook
โ X-Hub-Signature-256: sha256=<hmac-hex>
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. Verify HMAC-SHA256 of RAW body bytes โ
โ (constant-time comparison, timing attacks) โ
โ โ
โ 2. If invalid โ 401, drop โ
โ โ
โ 3. If valid โ enqueue payload โ return 200 โ
โ (must return 200 before Meta's timeout) โ
โโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโดโโโโโโโโโโโโโโโ
โผ โผ
Message Queue Event Queue
(inbound msgs) (status events)
โ โ
โผ โผ
Async Worker Async Worker
(parse, respond, (update delivery
session check, state, analytics,
route to bot) alert on failure)
โ NEVER: process synchronously, parse then re-stringify for signature,
or store raw payloads without enqueuing first.
Meta signs every POST with X-Hub-Signature-256: sha256=<hex>, HMAC-SHA256 of the raw request body bytes, keyed by your App Secret. Two critical rules: validate against the raw body (never JSON.stringify(parsedBody), whitespace differences break it), and use a constant-time comparison to prevent timing attacks.
// TypeScript, Next.js App Router route handler (simplified)
import { createHmac, timingSafeEqual } from "crypto";
export async function POST(request: Request) {
const rawBody = await request.arrayBuffer();
const rawBytes = Buffer.from(rawBody);
const sig = request.headers.get("x-hub-signature-256") ?? "";
const expected = "sha256=" + createHmac("sha256", process.env.WHATSAPP_APP_SECRET!)
.update(rawBytes)
.digest("hex");
// Constant-time comparison prevents timing attacks
const valid =
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!valid) return new Response("Unauthorized", { status: 401 });
// Enqueue immediately, process asynchronously
await queue.add("whatsapp-event", JSON.parse(rawBytes.toString("utf-8")));
return new Response("OK", { status: 200 }); // Must return before Meta's timeout
}
Every webhook POST wraps events in an entry array. Inbound messages and delivery statuses can arrive in the same payload, always handle both paths.
// Inbound message event
{
"object": "whatsapp_business_account",
"entry": [{
"id": "{WABA_ID}",
"changes": [{
"field": "messages",
"value": {
"metadata": { "phone_number_id": "..." },
"messages": [{
"id": "wamid.xxx", // Use this for reactions/read receipts
"from": "254712345678",
"type": "text",
"text": { "body": "Habari!" },
"timestamp": "1723890000"
}],
"statuses": [{ // Delivery status for your outbound messages
"id": "wamid.yyy",
"status": "delivered", // sent | delivered | read | failed
"recipient_id": "254712345678",
"timestamp": "1723890010"
}]
}
}]
}]
}
Graph API responses include an X-App-Usage header, a JSON object with call_count, total_time, and total_cputime expressed as percentages. Throttle proactively before any value hits 100. The API returns error code 4 for app-level throttling and code 17 for user-level. Implement exponential backoff on these codes.
WhatsApp Flows is a declarative UI framework that renders multi-screen, native UI journeys inside the chat thread. No external browser, no webview. Users fill forms, book appointments, and complete lead-capture sequences without leaving WhatsApp. From an architecture standpoint, Flows turns your webhook endpoint into a UI backend.
| Mode | How it works | When to use |
|---|---|---|
| Static Flow | Pure Flow JSON, no server. All screen logic is declared in the JSON. Collected data arrives at your webhook when the user submits. | Lead capture, surveys, simple forms. Fast to build, no data endpoint to secure. |
| Dynamic Flow | Your encrypted data endpoint receives data_exchange calls between screens. You validate input, fetch live data, and route the user. All traffic is end-to-end encrypted with your WABA key pair. |
Booking with live availability, conditional routing, real-time validation, personalized flows. |
WhatsApp App (on user's phone)
โ
โ screen transition / submit
โผ
WhatsApp Infrastructure
โ encrypts request with your WABA public key
โ
โ POST /your-flow-endpoint
โ (encrypted body)
โผ
Your Server
โ 1. Decrypt with WABA private key
โ 2. Parse action (screen_0_field, navigation_screen, ping)
โ 3. Apply business logic (validate, fetch live data)
โ 4. Encrypt response with same key pair
โ 5. Return encrypted response
โผ
WhatsApp Infrastructure โ renders next screen on user's device
Interactive messages (reply buttons, list menus) work inside a session window and cover simple one-step interactions, up to 3 buttons or 10 list rows. Flows handle multi-step, multi-screen journeys that go beyond what button/list interactions can express. They require more setup (Flow JSON authoring, key pair, optional data endpoint) but dramatically increase what you can accomplish inside the chat thread without redirecting to a browser.
Meta retired conversation-based pricing in July 2025. Every delivered template message is now billed individually by category. There is no free monthly conversation tier anymore. If your cost model was built on the old system, recalculate now.
| Category | Inside Window | Outside Window | Note |
|---|---|---|---|
| MARKETING | Billed | Billed | Always billed on delivery |
| UTILITY | Free | Billed | Best cost profile for transactional SaaS |
| AUTHENTICATION | Billed | Billed | Volume tiers reduce unit cost monthly |
| Service (free-form) | Free | Not allowed | Chatbot replies inside window cost nothing |
As of October 2025, messaging limits are set per business portfolio (not per phone number) and cap unique users you can message outside a service window per rolling 24 hours.
Scaling is algorithmic, not purchasable. You cannot buy a higher tier. Quality rating (Green / Yellow / Red) computed from blocks and reports determines whether you can scale up. A low quality score blocks upward movement even with sufficient volume. Practical levers: clean opt-in lists, message relevance, frequency discipline, easy opt-out in marketing templates.
Maximize free service messages by keeping the conversation open: respond to every user message quickly, encourage back-and-forth, and send UTILITY templates only when you genuinely need to restart a closed window. For Kenya (KE) deployments, verify the KE rate card separately, emerging market rates differ from US rates. And never misclassify marketing content as UTILITY, Meta re-categorizes and bills at the MARKETING rate.
X-App-Usage (JSON: call_count, total_time, total_cputime as %). Back off before any value hits 100.code 4: app rate limit. code 17: user rate limit. code 190: invalid/expired token. code 131026-series: message undeliverable (check specific subcode). Implement exponential backoff on 4 and 17; alert and rotate on 190.wamid through its lifecycle: sent โ delivered โ read. Failed status with a subcode tells you why. Only delivered messages bill, undelivered (e.g. invalid number) are not charged.JSON.stringify(parsedBody). Whitespace in the serialized JSON may differ from what Meta signed. Use a constant-time comparison (timingSafeEqual) to prevent timing attacks.