Cloud API
Every session in waxum has a provider: whatsapp_web (default -- the
unofficial multi-device protocol the rest of this API documents) or
whatsapp_cloud (Meta's official Business Cloud API). A whatsapp_cloud
session sends and receives through Meta's Graph API instead of a
WhatsApp Web socket, but reuses the same /messages/*
route surface wherever the two providers overlap -- an existing
integration mostly just needs a different session id to switch.
Connect
Attach Cloud API credentials to a session and flip its provider to
whatsapp_cloud.
From the built-in console at / (v0.13.2+), New Cloud API session
creates the session and attaches credentials in one step. The session's
page then shows the webhook callback URL and the Flow endpoint URL to
paste into your Meta app.
POST /api/v1/sessions/{session_id}/cloud/connect
Request Body
{
"waba_id": "102290129340398",
"phone_number_id": "106540352242922",
"business_id": "102290129340399",
"access_token": "EAAG...",
"app_id": "1234567890",
"app_secret": "your-meta-app-secret",
"webhook_verify_token": "a-token-you-choose"
}
| Field | Required | Description |
|---|---|---|
waba_id | Yes | WhatsApp Business Account ID |
phone_number_id | Yes | Phone number ID to send/receive through |
business_id | No | Meta Business ID that owns the WABA |
access_token | Yes | System-user or Embedded-Signup-exchanged access token |
app_id | No | Meta app ID your webhook is registered under |
app_secret | Yes | Meta app secret -- verifies X-Hub-Signature-256 on inbound webhooks |
webhook_verify_token | Yes | Echoed back on the webhook GET verification handshake |
access_token, app_secret and webhook_verify_token are write-only:
they are stored, but never returned by this or any other GET
response -- including GET /sessions/{id} and GET /sessions. Only the
non-secret identifiers come back:
Response
{
"session": {
"id": "my-cloud-session",
"provider": "whatsapp_cloud",
"cloud_waba_id": "102290129340398",
"cloud_phone_number_id": "106540352242922",
"cloud_business_id": "102290129340399",
"cloud_app_id": "1234567890"
}
}
Embedded Signup
Embedded Signup is Meta's client-side onboarding flow: your frontend
loads Meta's JS SDK, the client-business owner logs into their own Meta
Business account in a popup, and the SDK's message event hands your
page back an OAuth code plus the new WABA's id. This endpoint finishes
onboarding server-side:
POST /api/v1/sessions/{session_id}/cloud/embedded-signup/exchange
Request Body
{
"code": "the-oauth-code-from-the-js-sdk-callback",
"app_id": "1234567890",
"app_secret": "your-meta-app-secret",
"waba_id": "the-waba-id-from-the-js-sdk-callback"
}
Runs three calls against the Graph API in sequence: exchanges code for
an access token, subscribes your app to the WABA's webhooks
(POST {waba_id}/subscribed_apps), then lists the WABA's phone numbers
so you can show the caller a picker.
Response
{
"access_token": "EAAG...",
"waba_id": "102290129340398",
"phone_numbers": {
"data": [
{ "id": "106540352242922", "display_phone_number": "+1 555 0100", "verified_name": "Acme Support" }
]
}
}
This endpoint does not itself attach anything to the session --
access_token is returned here specifically so the caller can show a
picker over phone_numbers, then call
POST /cloud/connect with the number they picked and this
same access_token.
Sending Messages
A whatsapp_cloud session reuses the existing
/messages/* endpoints for every message type the
Cloud API supports: text, image, video, audio, document,
sticker, location, contact, react, buttons, list, and
read. Request/response shapes are identical to a whatsapp_web
session's -- the provider switch is transparent at the request level.
Two differences worth knowing:
- Media by URL or base64, not by whatsapp-rust's pre-uploaded
pointer.
image/video/audio/document/stickeraccept{"url": "..."}(sent as a Cloud APIlink) or{"data": "<base64>", "mimetype": "..."}(uploaded to the Cloud API first, then sent by the resulting media id). A whatsapp-rust{"url": ..., "direct_path": ..., "media_key": ..., ...}pointer fromPOST /media/uploadhas no Cloud API equivalent and is rejected with400. - Endpoints with no Cloud API equivalent -- groups, polls, calls,
presence, and the rest of the Web-only surface -- return the same
503awhatsapp_websession would get from an unconnected socket, since awhatsapp_cloudsession never opens one either. This is a known rough edge for those specific endpoints (they were never meant to be called on a Cloud session in the first place); every message type listed above dispatches for real.
Send a Template Message
Cloud-only -- there is no Web-protocol equivalent, since templates are a Cloud API / Business Solution concept.
POST /api/v1/sessions/{session_id}/messages/template
{
"to": "15551234567",
"name": "order_confirmation",
"language_code": "en_US",
"components": [
{
"type": "body",
"parameters": [{ "type": "text", "text": "Ada" }]
}
]
}
components is passed through to the Cloud API verbatim -- see Meta's
message-template component reference for the full parameter grammar
(text, currency, date_time, media headers, quick-reply button
payloads, ...). Returns 400 on a whatsapp_web session.
Media
Cloud API media has no shared shape with whatsapp-rust's upload
pointers (direct_path/media_key/file_sha256/...), so it gets its
own routes rather than overloading /media/upload:
POST /api/v1/sessions/{session_id}/cloud/media
GET /api/v1/sessions/{session_id}/cloud/media/{media_id}
GET /api/v1/sessions/{session_id}/cloud/media/{media_id}/download
DELETE /api/v1/sessions/{session_id}/cloud/media/{media_id}
POST /cloud/media is a multipart upload (field name file) that
returns {"id": "..."}. GET .../{media_id} resolves that id to its
metadata and a short-lived signed URL. GET .../{media_id}/download
resolves and fetches the bytes in one call, streaming them back as the
response body. DELETE .../{media_id} removes it from Meta's storage.
All four 400 on a whatsapp_web session.
Webhook Receiver
Meta delivers inbound messages and status updates to a per-session endpoint you register on your Meta app's dashboard:
GET /api/v1/sessions/{session_id}/cloud/webhook
POST /api/v1/sessions/{session_id}/cloud/webhook
Both bypass waxum's normal bearer-auth check -- Meta calls them directly and only ever presents its own proof, never a waxum token.
GET is Meta's one-time verification handshake, sent when you
register or change the webhook URL: it carries hub.mode,
hub.verify_token, and hub.challenge query params. waxum echoes
hub.challenge back with 200 when hub.mode == "subscribe" and
hub.verify_token matches the session's webhook_verify_token from
/cloud/connect; otherwise 403.
POST is an actual delivery, signed with
X-Hub-Signature-256: sha256=<hmac> over the raw body, keyed by the
session's app_secret -- a completely different scheme from
waxum's own outbound webhook signing
(X-Webhook-Signature, timestamp-prefixed). A bad or missing signature
gets 401 before the body is even parsed.
Inbound messages[] entries are normalized into the exact same
message event shape your existing
whatsapp_web webhook consumer already handles -- from,
from_phone, chat, chat_phone, message_id, is_from_me,
push_name, message_type, text, caption, media, location,
is_group (always false -- the Cloud API has no group messaging),
quoted_message_id, quoted_sender_jid -- and fanned out through the
same webhook registrations and retry/DLQ pipeline as
any other session.
A few Cloud-only fields are added on top of that shape. Each is null
unless it applies:
| Field | Set when |
|---|---|
interactive | message_type: "interactive" -- Meta's object as-is: button_reply, list_reply or nfm_reply |
flow_response | A Flow was completed -- nfm_reply.response_json, parsed into an object |
order | message_type: "order" -- a cart sent from your catalog: catalog_id, product_items, text |
referred_product | The customer wrote from a product's "Message business" button: catalog_id, product_retailer_id |
Delivery and payment status (receipt)
statuses[] updates become receipt events, one per status:
{
"session_id": "my-cloud-session",
"event": "receipt",
"data": {
"provider": "whatsapp_cloud",
"message_id": "wamid.HBgL...",
"recipient": "15551234567",
"status": "failed",
"timestamp": 1700000000,
"type": null,
"errors": [{ "code": 131047, "title": "Re-engagement message" }],
"conversation": null,
"pricing": null,
"payment": null
}
}
status is sent, delivered, read or failed. For a
payment it is captured, pending or failed, with
type: "payment" and payment.reference_id set. errors,
conversation and pricing are passed through from Meta when present.
Flows
WhatsApp Flows are multi-screen forms that run inside the chat.
Manage Flows
GET /api/v1/sessions/{session_id}/cloud/flows
POST /api/v1/sessions/{session_id}/cloud/flows
GET /api/v1/sessions/{session_id}/cloud/flows/{flow_id}
POST /api/v1/sessions/{session_id}/cloud/flows/{flow_id}
DELETE /api/v1/sessions/{session_id}/cloud/flows/{flow_id}
PUT /api/v1/sessions/{session_id}/cloud/flows/{flow_id}/json
GET /api/v1/sessions/{session_id}/cloud/flows/{flow_id}/assets
POST /api/v1/sessions/{session_id}/cloud/flows/{flow_id}/publish
POST /api/v1/sessions/{session_id}/cloud/flows/{flow_id}/deprecate
GET /api/v1/sessions/{session_id}/cloud/flows/{flow_id}/metrics
POST /api/v1/sessions/{session_id}/cloud/flows-migrate
- Create takes
{"name", "categories": ["APPOINTMENT_BOOKING", ...], "clone_flow_id"?}and creates a draft. POST .../{flow_id}updatesname,categoriesorendpoint_uri.PUT .../jsontakes the Flow JSON document as the request body.- Publish is irreversible. Only draft Flows can be deleted.
metricstakes?metric=ENDPOINT_REQUEST_COUNT&granularity=DAY&since=2026-01-01&until=2026-01-31.metricis one ofENDPOINT_REQUEST_COUNT,ENDPOINT_REQUEST_ERROR,ENDPOINT_REQUEST_ERROR_RATE,ENDPOINT_REQUEST_LATENCY_SECONDS_CEILorENDPOINT_AVAILABILITY.flows-migratecopies Flows from another WABA:{"source_waba_id", "source_flow_names"?}.
Send a Flow
POST /api/v1/sessions/{session_id}/cloud/flows/{flow_id}/send
{
"to": "15551234567",
"flow_cta": "Book now",
"body": "Pick a time for your appointment",
"header": "Acme Clinic",
"flow_token": "booking-7f3a",
"mode": "published",
"flow_action": "navigate",
"screen": "WELCOME",
"data": { "name": "Ada" }
}
flow_action is navigate (the default; opens screen directly, which
is then required) or data_exchange (asks your Flow endpoint for the
first screen). mode: "draft" only delivers to the WABA's test numbers.
When the user submits, the answers arrive as a message event with
flow_response set.
Flow endpoint (Data Exchange)
Flows that fetch data between screens call an endpoint you host. waxum can be that endpoint: it handles Meta's encryption and forwards each decrypted request to your own backend as plain JSON.
POST /api/v1/sessions/{session_id}/cloud/flow-endpoint
GET /api/v1/sessions/{session_id}/cloud/flow-endpoint/public-key
POST /api/v1/sessions/{session_id}/cloud/flow-endpoint/exchange
Configure the endpoint once:
{
"forward_url": "https://example.com/flows/handler",
"private_key": "-----BEGIN PRIVATE KEY-----\n..."
}
private_keyis optional. Omit it and waxum generates a 2048-bit RSA key itself, so the private key never leaves the server. PKCS#8 and PKCS#1 PEM are both accepted.- Registration. The public half is registered with Meta for you, and returned in the response.
- Storage. The private key is stored on the session and never returned by any
GET. forward_urlmust be a publichttp(s)URL. It is checked by the same SSRF guard as webhooks.
Then set the Flow's endpoint_uri (via POST .../flows/{flow_id}) to
https://<your-waxum-host>/api/v1/sessions/{session_id}/cloud/flow-endpoint/exchange.
/exchange is called by Meta directly and bypasses bearer auth,
like the webhook. For each request, waxum does the following:
- Verifies
X-Hub-Signature-256against the session'sapp_secret. On failure it returns432. - Decrypts: RSA-OAEP-SHA256 unwraps the AES-128 key, then AES-128-GCM decrypts the payload using Meta's 16-byte IV. Any decryption failure returns
421, which makes Meta re-fetch the public key. - Answers Meta's
pinghealth check itself. - POSTs every other request (
INIT,data_exchange,BACK) toforward_urlas JSON, with anX-Waxum-Session-Idheader. Your reply, e.g.{"screen": "CONFIRM", "data": {...}}, is encrypted with the flipped IV and returned to Meta.
With no forward_url configured, only ping is answered and other
requests get 503.
Commerce
GET /api/v1/sessions/{session_id}/cloud/commerce-settings
POST /api/v1/sessions/{session_id}/cloud/commerce-settings
POST /api/v1/sessions/{session_id}/messages/product
POST /api/v1/sessions/{session_id}/messages/product-list
POST /api/v1/sessions/{session_id}/messages/catalog
commerce-settingstogglesis_cart_enabledandis_catalog_visible.- Single product:
{"to", "catalog_id", "product_retailer_id", "body"?, "footer"?}. - Multi-product:
headerandbodyare required, plussections: [{"title", "product_retailer_ids": [...]}]. Meta's limits are checked before the request is sent: at most 10 sections and 30 products, and every section needs a title and at least one product. - Catalog:
{"to", "body", "footer"?, "thumbnail_product_retailer_id"?}.
A cart the customer sends back arrives as a message event with
order set.
Payments
India (UPI / payment gateways) and Singapore only.
POST /api/v1/sessions/{session_id}/messages/order-details
POST /api/v1/sessions/{session_id}/messages/order-status
order-details takes:
{
"to": "919876543210",
"region": "IN",
"body": "Your order",
"footer": "Thanks!",
"header": { "type": "image", "image": { "link": "https://..." } },
"parameters": {
"reference_id": "order-1001",
"type": "digital-goods",
"payment_type": "upi",
"payment_configuration": "my-payment-config",
"currency": "INR",
"total_amount": { "value": 50000, "offset": 100 },
"order": { "status": "pending", "items": [], "subtotal": { "value": 50000, "offset": 100 } }
}
}
parameters is Meta's payment object as-is. region is IN (the
default) or SG; waxum nests the message the way each region expects.
order-status takes {"to", "body", "reference_id", "status", "description"?}, where status is e.g. processing, shipped,
completed or canceled. Payment results arrive as
receipt events.
Templates
GET /api/v1/sessions/{session_id}/cloud/templates
POST /api/v1/sessions/{session_id}/cloud/templates
DELETE /api/v1/sessions/{session_id}/cloud/templates?name=...&hsm_id=...
GET /api/v1/sessions/{session_id}/cloud/templates/namespace
GET /api/v1/sessions/{session_id}/cloud/templates/{template_id}
POST /api/v1/sessions/{session_id}/cloud/templates/{template_id}
- List filters on
name,status,category,language,limit, and theafter/beforecursors. - Create takes
{"name", "language", "category": "MARKETING" | "UTILITY" | "AUTHENTICATION", "components": [...]}.componentsis Meta's component array (HEADER,BODY,FOOTER,BUTTONS), including catalog (CATALOG), multi-product (MPM), Flow and OTP buttons. - Edit (
POST .../{template_id}) changescategory,componentsormessage_send_ttl_seconds. - Delete by
nameremoves every language. Addinghsm_idremoves just one.
To send a template, see Send a Template Message.
Phone Number & Account
GET /api/v1/sessions/{session_id}/cloud/phone-number
GET /api/v1/sessions/{session_id}/cloud/phone-numbers
POST /api/v1/sessions/{session_id}/cloud/register
POST /api/v1/sessions/{session_id}/cloud/deregister
POST /api/v1/sessions/{session_id}/cloud/request-code
POST /api/v1/sessions/{session_id}/cloud/verify-code
POST /api/v1/sessions/{session_id}/cloud/two-step-pin
GET /api/v1/sessions/{session_id}/cloud/waba
GET /api/v1/sessions/{session_id}/cloud/owned-wabas
GET /api/v1/sessions/{session_id}/cloud/client-wabas
GET /api/v1/sessions/{session_id}/cloud/business-portfolio
GET /api/v1/sessions/{session_id}/cloud/debug-token
phone-numberincludes display name status, quality rating and messaging limit tier.registertakes the number's 6-digit two-step PIN:{"pin", "data_localization_region"?}. Add"backup": {"data", "password"}when migrating a number off the On-Premises API.request-codetakes{"code_method": "SMS" | "VOICE", "locale"?}.verify-codetakes{"code"}.two-step-pintakes{"pin"}.debug-tokenshows the stored access token's scopes, expiry and WABA permissions. It never returns the token itself.- Business ID required.
owned-wabas,client-wabasandbusiness-portfolioneed the session connected with abusiness_id. Otherwise they return400naming the missing field.
Business Profile
GET /api/v1/sessions/{session_id}/cloud/business-profile
POST /api/v1/sessions/{session_id}/cloud/business-profile
POST /api/v1/sessions/{session_id}/cloud/business-profile/photo
Update takes any of about, address, description, email,
vertical and websites (at most 2). Omitted fields are left unchanged.
photo is a multipart upload (field file, JPEG or PNG). waxum
runs Meta's Resumable Upload API and sets the photo in one call. This
needs the session connected with an app_id.
Typing Indicator
POST /api/v1/sessions/{session_id}/cloud/typing
{"message_id": "wamid..."} shows "typing…" to the customer and marks
their message as read. The indicator clears when you reply, or after
25 seconds.
QR Codes
GET /api/v1/sessions/{session_id}/cloud/qr-codes?format=SVG
POST /api/v1/sessions/{session_id}/cloud/qr-codes
GET /api/v1/sessions/{session_id}/cloud/qr-codes/{code}?format=PNG
POST /api/v1/sessions/{session_id}/cloud/qr-codes/{code}
DELETE /api/v1/sessions/{session_id}/cloud/qr-codes/{code}
- Create takes
{"prefilled_message", "generate_qr_image": "SVG" | "PNG"}and returns the code, itswa.medeep link and an image URL. POST .../{code}changes the prefilled message.
Blocked Users
GET /api/v1/sessions/{session_id}/cloud/blocked-users
POST /api/v1/sessions/{session_id}/cloud/blocked-users
DELETE /api/v1/sessions/{session_id}/cloud/blocked-users
Block and unblock both take {"users": ["15551234567", ...]}.
Webhook Subscriptions
GET /api/v1/sessions/{session_id}/cloud/subscribed-apps
POST /api/v1/sessions/{session_id}/cloud/subscribed-apps
DELETE /api/v1/sessions/{session_id}/cloud/subscribed-apps
A POST with no body subscribes your app to the WABA's webhooks.
With {"override_callback_uri", "verify_token"}, the WABA's webhooks go
to that URL instead of the app-level one. This is how one Meta app can
route different WABAs to different waxum sessions. Both fields are
required together, and the URL must be public.
Analytics & Billing
GET /api/v1/sessions/{session_id}/cloud/analytics?start=1700000000&end=1702600000&granularity=DAY
GET /api/v1/sessions/{session_id}/cloud/conversation-analytics?start=...&end=...&granularity=MONTHLY&dimensions=conversation_type,conversation_direction
GET /api/v1/sessions/{session_id}/cloud/credit-lines
startandendare Unix seconds.analyticsreturns message counts.granularityisHALF_HOUR,DAYorMONTH. Optional filters:phone_numbers,country_codes.conversation-analyticsreturns conversation counts and cost.granularityisHALF_HOUR,DAILYorMONTHLY. Optional filters:conversation_directions,dimensions,conversation_categories,conversation_types,phone_numbers,country_codes.- List filters are comma-separated.
credit-linesneeds abusiness_idon the session.
Business Compliance (India)
GET /api/v1/sessions/{session_id}/cloud/business-compliance
POST /api/v1/sessions/{session_id}/cloud/business-compliance
POST takes:
entity_nameentity_type(e.g.PRIVATE_COMPANY,SOLE_PROPRIETORSHIP)is_registered- optional
other_entity_type,grievance_officer_detailsandcustomer_care_details
Solution Partners (BSP)
These routes cover the Embedded Signup steps after the token exchange: giving your system user access to a client's WABA and sharing your line of credit with it.
GET /api/v1/sessions/{session_id}/cloud/system-users
GET /api/v1/sessions/{session_id}/cloud/assigned-users
POST /api/v1/sessions/{session_id}/cloud/assigned-users
POST /api/v1/sessions/{session_id}/cloud/credit-sharing
GET /api/v1/sessions/{session_id}/cloud/credit-sharing/{allocation_config_id}
DELETE /api/v1/sessions/{session_id}/cloud/credit-sharing/{allocation_config_id}
GET /api/v1/sessions/{session_id}/cloud/credit-lines/{credit_line_id}/allocations
assigned-userstakes{"user_id", "tasks": ["MANAGE"]}.credit-sharingtakes{"credit_line_id", "waba_currency": "USD"}. It attaches your credit line to the session's WABA and returns theallocation_config_id.GET credit-sharing/...confirms the share (receiving_credential).DELETErevokes it.
Errors
All of the routes above return 400 on a whatsapp_web session. When
Meta rejects a request (a 4xx from the Graph API), waxum returns 400
with Meta's error body, since the request itself usually needs fixing.
A Meta 5xx or a network failure returns 500.