LLM Integration Prompt
Copy the text below and paste it into your LLM (Claude, ChatGPT, etc.) when you need to implement the WhatsApp Cloud API integration in another product. It contains the full API surface, onboarding flow, webhook payloads, and message types — everything an LLM needs to write your integration code.
The Prompt
Paste everything below the line into your LLM conversation.
You are integrating with the WhatsApp Cloud API. This document describes the full API surface you need to implement against. Use the base URL and API key provided in the project's environment configuration.
Base URL
Production base URL: https://wpp-cloud.com/v1
Prerequisites
The integration needs exactly three inputs. If any is missing, ask the user for it before writing code:
| Input | What it is | How the user obtains it |
|---|---|---|
| Base URL | API root for all requests | Documented above (https://wpp-cloud.com/v1) |
| API key | Organization-scoped Bearer token (sk_org_...) |
Issued by the platform administrator — the user must request it. Keys cannot be self-created through the public API. |
| phoneId | Internal UUID of the WhatsApp line | See below — either look it up or run the onboarding flow |
Validate the API key with GET /v1/organizations/me — a 200 returns the organization;
401 means the key is invalid.
Obtain the phoneId, one of two ways:
- Phone already connected:
GET /v1/wabasreturns each WABA with itsphoneNumbers[]. The phone'sidfield (UUID) is thephoneIdfor all/v1/phones/{phoneId}/...paths. ⚠️ In that response the field literally namedphoneNumberIdis Meta's numeric ID — never use it in API paths. - Phone not connected yet: run the onboarding flow (Step 1 below). When completed,
GET /v1/onboarding-links/{id}returnsphoneNumberId— in that response it IS the internal UUID to use asphoneId(same field name, different meaning than in the WABA list).
Verify the line is ready before production traffic: GET /v1/phones/{phoneId}/health —
data.capabilities.canSendMessages and canReceiveMessages should both be true. Then send a
test text message and expect a 200 with a wamid.
Authentication
All API requests require a Bearer token:
Authorization: Bearer <API_KEY>
Error responses: 401 (missing/invalid key), 403 (no access).
Data Model
Organization
├── API Keys
├── Onboarding Links
├── Shared Inbox Links
├── Outgoing Webhooks
└── WABAs (WhatsApp Business Accounts)
└── Phone Numbers
└── Conversations
└── Messages
- Organization — Top-level account. All resources belong to an organization.
- WABA — WhatsApp Business Account from Meta. Has its own access token, can have multiple phone numbers.
- Phone Number — A WhatsApp number connected to a WABA. Messages are sent/received through phone numbers.
- Contact — A WhatsApp customer inside one phone inbox. A contact can have a phone, WhatsApp ID, BSUID, and username.
- Conversation — A thread between a phone number and a contact identity. Do not assume every contact has a phone number.
- Message — A single message within a conversation (inbound or outbound).
- Shared Inbox Link — A public URL bound to one phone number's inbox with a restricted permission set.
IDs are UUID v4 (internal) or numeric strings (Meta IDs in webhook payloads).
Response Format
Success (single): { "data": { ... } }
Success (list): { "data": [...], "meta": { "pagination": { "page": 1, "limit": 50, "total": 120, "hasMore": true } } }
Error: { "error": { "message": "...", "code": "NOT_FOUND" } }
Error codes: VALIDATION_ERROR (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), INTERNAL_ERROR (500).
Step 1: Connect a WhatsApp Number (Onboarding Links)
Onboarding links let you onboard end-users' WhatsApp numbers without building any frontend. You create a link via the API, send the hosted URL to your end-user, and they complete Meta's Embedded Signup in the browser. After completion, we redirect back to your app and fire a webhook event.
Flow
- Your Server → API:
POST /v1/onboarding-linkswith{name, externalId, metadata?, redirectUrl, webhookUrl?, webhookSecret?, webhookEvents?} - API → Your Server: Returns
{id, hostedUrl, status: "pending", ...} - Your Server → End-User: Send the
hostedUrl(email, in-app link, etc.) - End-User → API: Opens
hostedUrlin browser — serves the hosted signup page - End-User → Meta: Clicks "Connect WhatsApp" —
FB.login()popup for Embedded Signup - Meta → End-User: Returns OAuth code after user completes signup
- End-User → API:
POST /signup/:id/callbackwith the OAuth code - API: Processes signup — exchanges code for tokens, sets up WABA & phone number
- API → Your Server: Fires
phone_number.connectedwebhook event - API → End-User: Redirects to
redirectUrl?onboarding_link_id=x&external_id=y&status=success - Your Server → API:
GET /v1/onboarding-links/:id— fetch the completed result
Create an Onboarding Link
POST /v1/onboarding-links
{
"name": "Acme Corp",
"externalId": "customer-123",
"metadata": {
"customerId": "customer-123",
"locationCode": "store-01"
},
"redirectUrl": "https://myapp.com/whatsapp/callback",
"webhookUrl": "https://myapp.com/webhooks/whatsapp",
"webhookSecret": "whsec_my_secret_key_123",
"webhookEvents": ["message.received", "message.sent"]
}
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Display name shown in signup page |
| externalId | string | No | Your internal identifier for this link |
| metadata | object | No | Structured customer metadata copied to the connected phone |
| redirectUrl | string | No | URL to redirect to after signup completes |
| webhookUrl | string | No | URL for an outgoing webhook auto-registered when the phone connects |
| webhookSecret | string | No | HMAC signing secret for the webhook (16-256 chars) |
| webhookEvents | string[] | No | Event types for the webhook (required if webhookUrl is set) |
When webhookUrl is provided, an outgoing webhook is automatically created and scoped to the new WABA once the phone number connects. This saves a separate POST /v1/outgoing-webhooks call.
Response (201):
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Corp",
"status": "pending",
"externalId": "customer-123",
"metadata": {
"customerId": "customer-123",
"locationCode": "store-01"
},
"redirectUrl": "https://myapp.com/whatsapp/callback",
"hostedUrl": "https://wpp-cloud.com/signup/550e8400-...",
"phoneNumberId": null,
"phoneNumber": null,
"wabaId": null,
"isPhoneRegistered": null,
"webhookUrl": "https://myapp.com/webhooks/whatsapp",
"webhookSecret": "whsec_my_secret_key_123",
"webhookEvents": ["message.received", "message.sent"],
"createdAt": "2024-01-15T10:30:00.000Z",
"completedAt": null
}
}
Redirect After Completion
On success: redirectUrl?onboarding_link_id=x&external_id=customer-123&status=success
On error: redirectUrl?onboarding_link_id=x&external_id=customer-123&status=error&error=brief+message
If no redirectUrl is set, the hosted page shows a success/error message inline.
Get Onboarding Link (Enriched)
GET /v1/onboarding-links/{id}
When completed:
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Corp",
"status": "completed",
"externalId": "customer-123",
"metadata": {
"customerId": "customer-123",
"locationCode": "store-01"
},
"redirectUrl": "https://myapp.com/whatsapp/callback",
"hostedUrl": "https://wpp-cloud.com/signup/550e8400-...",
"phoneNumberId": "660e8400-e29b-41d4-a716-446655440001",
"phoneNumber": "+5491155551234",
"wabaId": "770e8400-e29b-41d4-a716-446655440002",
"isPhoneRegistered": true,
"webhookUrl": "https://wpp-cloud.com/webhook/phone/660e8400-...",
"createdAt": "2024-01-15T10:30:00.000Z",
"completedAt": "2024-01-15T10:35:00.000Z"
}
}
Use phoneNumberId from this response to identify the phone, then check readiness before sending traffic. The same metadata is returned by GET /v1/phones/{phoneNumberId}.
Check Phone Health / Readiness
GET /v1/phones/{phoneId}/health
Read the fields in data.capabilities:
canReceiveMessages: true= the phone is ready for inbound trafficcanSendMessages: true= the phone is ready for outbound messagingcanSendTemplates: true= the phone is ready for outbound template messagingfalse= the phone is not fully ready yet, even if it is already connected or registered
This endpoint also triggers an asynchronous resync against Meta in the background. If local state was stale, a later call may return updated readiness values.
Inspect Connected Business Metadata
GET /v1/wabas/{wabaId}/connection
Use the internal wabaId returned by onboarding. This org-only endpoint exposes the connected Meta account metadata:
data.waba.name,currency,metaWabaId,ownerBusiness,pageId,datasetIddata.connectedAccount.business— Meta Business Portfolio ID/namedata.connectedAccount.waba— Meta WABA ID/name/currency/template namespacedata.connectedAccount.pages— Facebook Pages visible for the owner business, with flags for Embedded Signup and CAPI selectiondata.connectedAccount.pageIds,datasetIds,catalogIds,instagramAccountIds— IDs captured from Embedded Signupdata.connectedAccount.marketingMessagesOnboardingStatusdata.connectedAccount.graphLookupErrors— non-fatal Graph lookup errors; stored IDs can still be present
If you already call GET /v1/phones/{phoneId} with an organization API key for phone readiness,
the same data.connectedAccount metadata is included there. Use the WABA connection endpoint when
you also need the linked phones and onboarding history.
Other Onboarding Link Endpoints
GET /v1/onboarding-links— List all (with pagination)DELETE /v1/onboarding-links/{id}— Delete (only when status ispending)
Step 2: Set Up Webhooks
Register a webhook to get notified of incoming messages, status updates, and phone connections.
POST /v1/outgoing-webhooks
{
"url": "https://yourapp.com/webhooks/whatsapp",
"secret": "whsec_your_secret_key_min_16_chars",
"events": ["message.received", "message.sent", "phone_number.connected"],
"isActive": true
}
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | Yes | Your HTTPS endpoint URL |
| secret | string | No | Signing secret for HMAC-SHA256 verification (16-256 chars) |
| events | string[] | Yes | At least one event type |
| isActive | boolean | No | Whether active (default: true) |
Event Types
| Event | Description |
|---|---|
message.received |
Message received from a contact |
message.sent |
Message sent to a contact |
message.status_updated |
Message status changed (delivered, read, failed) |
phone_number.connected |
Phone number connected via onboarding |
phone_number.status_updated |
Phone lifecycle event (quality rating, messaging limit, ban state, name update, health state) — filter by data.event |
* |
Subscribe to all events |
Note: there are no message.delivered, message.read, or message.failed event types. All
status changes arrive as message.status_updated — filter by data.status (sent, delivered,
read, failed). Subscribing to a non-existent event name fails validation.
Note: phone_number.connected means onboarding finished. It does not guarantee the phone is operationally ready for traffic. Use GET /v1/phones/{phoneId}/health and check the relevant field in data.capabilities for that. To monitor readiness changes in real time, subscribe to phone_number.status_updated.
Note: inbound messages that originate from a Click-to-WhatsApp ad or a Facebook/Instagram post include a data.referral object in the message.received payload (sourceType, sourceId, sourceUrl, headline, body, mediaType, ctwaClid, etc.) for attribution.
Optional: Shared Inbox Links
Shared inbox links let you expose one phone number's inbox through the hosted inbox frontend without giving the external user your organization API key.
Create one with:
POST /v1/shared-inbox-links
{
"phoneNumberId": "phone-uuid",
"name": "Support access",
"expiresAt": null,
"permissions": {
"readInbox": true,
"readDetails": true,
"markRead": true,
"sendText": true,
"sendMedia": true,
"sendTemplates": true,
"sendAdvanced": false
}
}
Response includes publicUrl. Send that URL to the external user. The frontend exchanges the public token into a short-lived shared session automatically.
Permission keys:
readInboxreadDetailsmarkReadsendTextsendMediasendTemplatessendAdvanced
Useful endpoints:
GET /v1/shared-inbox-links— List linksGET /v1/shared-inbox-links/{sharedInboxLinkId}— Get onePATCH /v1/shared-inbox-links/{sharedInboxLinkId}— Update name, expiry, or permissionsDELETE /v1/shared-inbox-links/{sharedInboxLinkId}— Revoke immediatelyPOST /v1/shared-inbox-links/{sharedInboxLinkId}/rotate— Generate a fresh public URL
Notes:
- links are scoped to exactly one
phoneNumberId expiresAt: nullmeans no expiration- rotating changes the public URL
- revoking invalidates active shared sessions on the next request
Webhook Payload
{
"event": "message.received",
"timestamp": "2026-03-03T15:30:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"phoneNumber": "+15551234567",
"contactPhone": "5491155551234",
"contactName": "John Doe",
"contactId": "contact-uuid",
"contactIdentifier": "5491155551234",
"contactIdentifierType": "phone",
"contactReplyTo": "5491155551234",
"businessScopedUserId": null,
"parentBusinessScopedUserId": null,
"username": null,
"countryCode": null,
"direction": "inbound",
"type": "text",
"content": { "text": "Hi, I'd like to place an order" },
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"status": "delivered",
"externalId": "customer-123",
"lastInboundMessageAt": "2026-03-03T15:30:00.000Z",
"serviceWindowExpiresAt": "2026-03-04T15:30:00.000Z",
"error": null
}
}
Inbound text lives in content.text — there is no content.body field.
Every message event carries lastInboundMessageAt (timestamp of the contact's last inbound
message) and serviceWindowExpiresAt (when the 24h customer service window closes —
lastInboundMessageAt + 24h, null if the contact never wrote). Use them to decide between a
free-form message and a template before sending.
The externalId field is present in all message events (message.received, message.sent, message.status_updated). It contains the externalId from the onboarding link that connected the phone, or null if not applicable.
Use contactIdentifier as your contact key and contactReplyTo as the outbound to value. Do not require contactPhone; it can be null.
Phone number format: contactPhone, contactIdentifier, and contactReplyTo carry the
WhatsApp ID (wa_id) exactly as Meta sends it — digits only, country code included, no +, no
normalization. Argentine numbers include the mobile 9 (5491155551234); Mexican numbers the 1
(5215512345678). Use the same value as the outbound to when replying.
Webhook content by message type (inbound):
data.type |
data.content fields |
|---|---|
text |
{ text } |
image / video |
{ mediaId, mimeType, caption?, mediaUrl, downloadUrl } |
audio / sticker |
{ mediaId, mimeType, mediaUrl, downloadUrl } |
document |
{ mediaId, mimeType, filename?, caption?, mediaUrl, downloadUrl } |
interactive (button reply) |
{ buttonId, buttonTitle, text } |
interactive (list reply) |
{ listId, listTitle, listDescription?, text } |
interactive (flow reply) |
{ flowName, flowBody, flowResponseJson, text } |
button (template quick reply) |
{ text, payload } |
location |
{ latitude, longitude, name?, address? } |
contacts |
{ contacts: [...] } |
reaction |
{ reactionEmoji, reactedToMessageId } |
When the inbound message quotes a previous message, content also includes quotedMessageId,
quotedMessageWamid, quotedMessageType, quotedMessagePreview, and quotedMessageDirection.
Status updates arrive as message.status_updated with data.status (sent, delivered,
read, failed) and, on failures, the raw Meta error in data.error:
{
"event": "message.status_updated",
"timestamp": "2026-03-03T15:33:00.000Z",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"messageId": "msg-uuid",
"conversationId": "conv-uuid",
"phoneNumberId": "phone-uuid",
"contactReplyTo": "5491155551234",
"direction": "outbound",
"type": "text",
"content": { "text": "Hello again!" },
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"status": "failed",
"error": {
"code": 131047,
"title": "Re-engagement message",
"error_data": {
"details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number."
}
}
}
}
Media messages: For inbound media messages (image, video, audio, document, sticker), the content object includes both a metadata URL and a direct download URL:
{
"type": "image",
"content": {
"mediaId": "1234567890",
"mediaUrl": "https://wpp-cloud.com/v1/phones/phone-uuid/media/1234567890",
"downloadUrl": "https://wpp-cloud.com/v1/phones/phone-uuid/media/1234567890/download",
"caption": "Photo of the product"
}
}
Use downloadUrl to stream the actual file bytes.
Use mediaUrl when you need metadata (id, mimeType, fileSize, filename) plus the temporary Meta download URL.
Both endpoints require Authorization: Bearer <apiKey>.
phone_number.connected Payload
{
"event": "phone_number.connected",
"timestamp": "2024-01-15T10:35:00.000Z",
"organizationId": "org_uuid",
"data": {
"onboardingLinkId": "link_uuid",
"externalId": "customer-123",
"phoneNumberId": "phone_uuid",
"phoneNumber": "+5491155551234",
"wabaId": "waba_uuid",
"metaWabaId": "meta_waba_id",
"isPhoneRegistered": true
}
}
HTTP Headers on Every Delivery
| Header | Description |
|---|---|
Content-Type |
application/json |
User-Agent |
WhatsApp-CloudAPI-Webhook/1.0 |
X-Webhook-Event |
The event type |
X-Webhook-Timestamp |
ISO 8601 timestamp |
X-Webhook-Delivery |
Unique delivery ID (for deduplication) |
X-Webhook-Signature-256 |
HMAC-SHA256 signature (only if secret is set) |
Verifying Signatures
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const received = signatureHeader.replace("sha256=", "");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));
}
Your endpoint must return a 2xx status within 30 seconds.
Other Webhook Endpoints
GET /v1/outgoing-webhooks— List allGET /v1/outgoing-webhooks/{webhookId}— Get onePATCH /v1/outgoing-webhooks/{webhookId}— UpdateDELETE /v1/outgoing-webhooks/{webhookId}— DeletePOST /v1/outgoing-webhooks/{webhookId}/test— Send test deliveryGET /v1/outgoing-webhooks/{webhookId}/logs— View delivery logs
Optional: Realtime WebSocket
Every event delivered to webhooks is also pushed over a WebSocket with the identical payload — same event names, same data shapes as the Webhook Payload section above. Use it for live UIs; keep webhooks as the guaranteed channel (the WebSocket is best-effort, with no retries and no replay).
GET wss://<host>/v1/realtime
The connection is organization-scoped: it receives events for all phones unless filtered.
Authentication — two paths:
- Server-side clients set a header on the handshake:
Authorization: Bearer sk_org_... - Browsers cannot set WebSocket headers. Mint a single-use ephemeral secret server-side, then connect with it in the query string:
POST /v1/realtime/client_secrets
Authorization: Bearer sk_org_...
Content-Type: application/json
{ "expiresInSeconds": 60, "phoneNumberIds": null, "events": null }
Response: { "data": { "value": "ek_...", "expiresAt": "..." } }. All body fields optional; expiresInSeconds defaults to 60 (min 10, max 600); phoneNumberIds (max 100 internal phone UUIDs) and events scope what the connection receives. The secret is single-use: mint a fresh one per connection attempt. The browser connects with:
wss://<host>/v1/realtime?client_secret=ek_...
Never expose sk_org_ keys to browsers — only ek_ secrets.
Filtering on API-key connections: query params ?phoneNumberIds=<uuid>,<uuid>&events=message.received,message.status_updated. Filters are best-effort delivery filters, not an authorization boundary.
Protocol rules the client MUST follow:
- First message received is
{ "event": "connection.established", ..., "data": { "connectionId", "phoneNumberIds", "events" } }. - Send the literal string
pingevery ~30s; the server replies with the bare stringpong(not JSON — skip it before JSON.parse). Repeatedly sending anything else closes the connection with code 1008. - Treat >75s without any incoming frame as a dead connection: close and reconnect.
- Reconnect with exponential backoff (1s doubling, cap 30s). Every API deploy disconnects all clients, so reconnection logic is mandatory.
- After reconnecting, re-sync missed state via the REST API (Step 4) — events are not replayed.
- The socket is server→push only: send WhatsApp messages via the REST API, never over the socket.
Handshake errors: 401 (bad key/secret, or secret already used/expired), 400 (invalid filters), 426 (missing Upgrade: websocket), 429 (over 100 concurrent connections per organization).
Step 3: Send Messages
All message types use a single endpoint. The type field determines the payload shape.
POST /v1/phones/{phoneId}/messages
Common fields: type (string, required), to (string, required — recipient phone with country
code, digits only, no +; when replying to a webhook use data.contactReplyTo verbatim).
Response: { "data": { "messageId": null, "wamid": "wamid.HBgL..." } }
messageId is always null in this response — correlate sends with webhooks and the messages API
through the wamid. A 200 with a wamid means Meta accepted the message, not that it was
delivered; final state arrives via message.status_updated.
Text
{ "type": "text", "to": "5491155551234", "text": "Hello!", "previewUrl": false }
Image / Video / Audio / Document / Sticker
All five media types accept either a public URL or a pre-uploaded media ID.
By URL:
{ "type": "image", "to": "5491155551234", "mediaUrl": "https://example.com/photo.jpg", "caption": "Check this out!" }
{ "type": "document", "to": "5491155551234", "mediaUrl": "https://example.com/coupons/coupon-1234.pdf", "filename": "coupon-1234.pdf", "caption": "Your coupon" }
By pre-uploaded media ID:
{ "type": "document", "to": "5491155551234", "mediaId": "1234567890", "caption": "Report", "filename": "report.pdf" }
Either mediaId or mediaUrl must be provided, not both. mediaUrl is fetched by Meta's
servers — it must be publicly reachable over HTTPS (no auth, no private IPs); otherwise upload
first and send by mediaId.
Upload media: POST /v1/phones/{phoneId}/media (multipart/form-data, file field) returns { "data": { "id": "1234567890" } }
Audio format: The API does not transcode audio — it validates and forwards the file as-is. Supported: audio/aac, audio/amr, audio/mpeg, audio/mp4, audio/ogg; codecs=opus (max 16MB). For a playable voice note, send OGG/Opus, mono, with MIME audio/ogg; codecs=opus. audio/webm (the browser MediaRecorder default) is not supported and is rejected — encode to OGG/Opus before uploading.
Template
{
"type": "template",
"to": "5491155551234",
"templateName": "order_confirmation",
"languageCode": "en_US",
"parameters": {
"header": [{ "type": "text", "text": "Order #1234" }],
"body": [{ "type": "text", "text": "John" }, { "type": "text", "text": "$99.00" }],
"buttons": [{ "type": "button", "subType": "url", "index": 0, "parameters": [{ "type": "text", "text": "order-1234" }] }]
}
}
Interactive Button
{
"type": "interactive",
"to": "5491155551234",
"interactiveType": "button",
"body": "How would you like to proceed?",
"header": { "type": "text", "text": "Choose an option" },
"footer": "Reply within 24 hours",
"buttons": [
{ "id": "btn_buy", "title": "Buy Now" },
{ "id": "btn_info", "title": "More Info" }
]
}
Constraints: 1-3 buttons, title max 20 chars. header (text only) and footer are optional.
The contact's tap arrives as a message.received webhook with type: "interactive" and
content.buttonId / content.buttonTitle.
Interactive List
{
"type": "interactive",
"to": "5491155551234",
"interactiveType": "list",
"body": "Browse our catalog:",
"buttonText": "View Products",
"sections": [
{ "title": "Electronics", "rows": [
{ "id": "phone_1", "title": "Smartphone X", "description": "Latest model" }
]}
]
}
CTA URL
{
"type": "cta_url",
"to": "5491155551234",
"bodyText": "Visit our store",
"buttonText": "Open Store",
"url": "https://store.example.com",
"headerText": "Special Offer",
"footerText": "Offer expires soon"
}
Flow
{
"type": "flow",
"to": "5491155551234",
"bodyText": "Complete your registration",
"flowToken": "token_abc123",
"flowId": "1234567890",
"flowCta": "Start Registration",
"flowAction": "navigate",
"screen": "WELCOME_SCREEN",
"data": { "userId": "usr_123" }
}
Reaction
{
"type": "reaction",
"to": "5491155551234",
"messageId": "msg-uuid",
"emoji": "👍"
}
messageId is the internal message UUID (not the wamid). Send an empty emoji ("") to remove a reaction. Reactions are aggregated onto the target message's reactions[] field, not returned as standalone messages.
Mark as Read
POST /v1/phones/{phoneId}/messages/{messageId}/read
messageId is the internal message UUID of an inbound message. Updates the local status to read; WhatsApp sends no status webhook back for this, so this endpoint is the only place read state is recorded.
{ "showTypingIndicator": false }
Delete a Message (local only)
DELETE /v1/phones/{phoneId}/messages/{messageId}
Soft-deletes a message locally so it disappears from listings. Meta has no API to delete/recall a sent message for everyone, so this is "delete for me" only — the recipient keeps the message. Organization API keys only (not shared inbox links).
The 24-Hour Customer Service Window
WhatsApp only allows free-form messages (text, media, interactive, etc.) within 24 hours of the
contact's last inbound message. Outside that window only template messages are accepted; a
contact reply re-opens the window. The API does not pre-validate the window — out-of-window sends
fail with Meta error 131047, either synchronously (HTTP 400) or asynchronously (a
message.status_updated webhook with data.status: "failed" and data.error.code: 131047).
Handle both paths.
To check the window before sending, read isServiceWindowOpen / serviceWindowExpiresAt from the
conversation endpoints, or serviceWindowExpiresAt from any message webhook event. When the window
is closed, send a template instead of failing against Meta.
Send Errors
Synchronous Meta rejections return HTTP 400 with code MESSAGE_SEND_FAILED and the raw Meta
error JSON embedded in error.message after the WhatsApp API Error: prefix:
{
"error": {
"message": "WhatsApp API Error: {\"error\":{\"message\":\"(#131047) Re-engagement message\",\"type\":\"OAuthException\",\"code\":131047,\"error_data\":{\"messaging_product\":\"whatsapp\",\"details\":\"Message failed to send because more than 24 hours have passed since the customer last replied to this number.\"},\"fbtrace_id\":\"AbCdEfGh123\"}}",
"code": "MESSAGE_SEND_FAILED"
}
}
Strip the prefix and parse the JSON to read Meta's code and error_data.details. Schema
violations (missing fields, >3 buttons, etc.) return 400 VALIDATION_ERROR without reaching Meta.
Asynchronous failures arrive as message.status_updated with data.status: "failed" and the
Meta error in data.error (see the webhook section above).
Common Meta error codes: 131047 (outside 24h window — send a template), 131026 (recipient
undeliverable / not on WhatsApp), 131056 (pair rate limit — too many messages to the same
recipient, throttle and retry), 131048 (spam rate limit), 100 (invalid parameter or
unreachable mediaUrl), 133010 (phone not registered).
Rate Limits
The API adds no rate limiting, queueing, or per-recipient locking of its own — there is no
409-style concurrency error. Meta's limits apply directly: ~80 messages/second per phone by
default, a pair rate limit on bursts to the same recipient (131056), and a messaging limit tier
capping unique business-initiated conversations per 24h (check GET /v1/phones/{phoneId}/health).
If your automation can burst multiple messages to one recipient, add a small delay between them.
Step 4: Read Conversations & Messages
List Conversations
GET /v1/phones/{phoneId}/conversations?page=1&limit=50
{
"data": [
{
"id": "conv-uuid",
"contactPhone": "5491155551234",
"contactName": "John Doe",
"contactIdentifier": "5491155551234",
"contactIdentifierType": "phone",
"contactReplyTo": "5491155551234",
"lastMessageAt": "2026-03-10T14:30:00.000Z",
"lastInboundMessageAt": "2026-03-10T14:30:00.000Z",
"serviceWindowExpiresAt": "2026-03-11T14:30:00.000Z",
"isServiceWindowOpen": true,
"createdAt": "2026-03-01T10:00:00.000Z"
}
],
"meta": { "pagination": { "page": 1, "limit": 50, "total": 12, "hasMore": false } }
}
lastInboundMessageAt, serviceWindowExpiresAt, and isServiceWindowOpen describe the 24h
customer service window: when isServiceWindowOpen is true, free-form messages can be sent;
when false, send a template instead. Computed from local data — Meta remains the source of truth.
Get Messages
GET /v1/phones/{phoneId}/conversations/{conversationId}/messages?page=1&limit=50
{
"data": [
{
"id": "msg-uuid",
"conversationId": "conv-uuid",
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"direction": "inbound",
"type": "text",
"content": "Hi there!",
"status": "delivered",
"timestamp": "2026-03-10T14:30:00.000Z",
"editedAt": null,
"mediaId": null,
"mediaUrl": null,
"mimeType": null,
"filename": null,
"caption": null,
"reactions": [
{
"emoji": "👍",
"reactor": "business",
"direction": "outbound",
"timestamp": "2026-03-10T14:31:00.000Z"
}
]
}
]
}
Important — two different content shapes: the messages REST API returns a flattened
message where content is a plain string (the extracted display text: the text body, the media
caption, or the tapped button title). Type-specific data is exposed as flat top-level fields. This
differs from webhook payloads, where data.content is an object ({ text }, { mediaId, ... }).
Do not assume the two shapes are interchangeable.
Flattened Message Fields (messages API)
| Field group | Fields |
|---|---|
| Core | id, conversationId, wamid, direction, type, content (string), status, timestamp, editedAt |
| Media | mediaId, mediaUrl, mimeType, filename, caption, transcript |
| Reactions | reactionEmoji, reactedToMessageId, reactions[] ({ emoji, reactor, direction, timestamp }) |
| Template | templateName, templateLanguage, templateParameters |
| Interactive | interactiveType, buttons, interactiveHeader, interactiveBody, interactiveFooter, interactiveSections |
| Quoted reply | quotedMessageId, quotedMessageWamid, quotedMessageType, quotedMessagePreview, quotedMessageDirection |
| CTA URL | ctaUrlBodyText, ctaUrlButtonText, ctaUrlUrl, ctaUrlHeaderText, ctaUrlHeaderImage, ctaUrlFooterText |
Fields that do not apply to the message type are null.
Message Statuses
pending → sent → delivered → read (or failed at any point). Status only ever advances — out-of-order webhooks never move it backwards. Inbound messages are stored as delivered and become read once marked read.
Reactions are not returned as standalone messages — they are aggregated onto the target message's reactions[] array.
Implementation Checklist
- Create onboarding link —
POST /v1/onboarding-linkswithredirectUrlpointing to your app. Optionally includewebhookUrl,webhookSecret, andwebhookEventsto auto-register a webhook when the phone connects. - Display hosted URL — Show
hostedUrlto end-users so they can connect their WhatsApp number - Handle redirect — Parse
onboarding_link_id,external_id, andstatusfrom redirect query params - Handle webhook — Listen for
phone_number.connectedfor server-to-server reliability - Fetch result —
GET /v1/onboarding-links/:idto getphoneNumberId,phoneNumber,wabaId - Store phone number ID — Save
phoneNumberId— this is the key for all messaging operations - Inspect connected account metadata —
GET /v1/wabas/{wabaId}/connectionto read Business Portfolio, WABA, Page, dataset, catalog, and Instagram metadata - Register webhook —
POST /v1/outgoing-webhooksto receivemessage.receivedevents (skip if you usedwebhookUrlin step 1) - Send messages —
POST /v1/phones/{phoneId}/messageswith the appropriate type - Verify webhook signatures — HMAC-SHA256 with your secret for security
- Read conversations —
GET /v1/phones/{phoneId}/conversations+ messages endpoint - Handle media in webhooks — Use
content.downloadUrlto download the file bytes, orcontent.mediaUrlto fetch metadata (both require Bearer auth) - Handle failures — Subscribe to
message.status_updatedand treatdata.status: "failed"as the delivery source of truth; handle 400MESSAGE_SEND_FAILEDon sends (parse the embedded Meta error, fall back to a template message on error 131047)