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:

  1. Phone already connected: GET /v1/wabas returns each WABA with its phoneNumbers[]. The phone's id field (UUID) is the phoneId for all /v1/phones/{phoneId}/... paths. ⚠️ In that response the field literally named phoneNumberId is Meta's numeric ID — never use it in API paths.
  2. Phone not connected yet: run the onboarding flow (Step 1 below). When completed, GET /v1/onboarding-links/{id} returns phoneNumberId — in that response it IS the internal UUID to use as phoneId (same field name, different meaning than in the WABA list).

Verify the line is ready before production traffic: GET /v1/phones/{phoneId}/healthdata.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

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

  1. Your Server → API: POST /v1/onboarding-links with {name, externalId, metadata?, redirectUrl, webhookUrl?, webhookSecret?, webhookEvents?}
  2. API → Your Server: Returns {id, hostedUrl, status: "pending", ...}
  3. Your Server → End-User: Send the hostedUrl (email, in-app link, etc.)
  4. End-User → API: Opens hostedUrl in browser — serves the hosted signup page
  5. End-User → Meta: Clicks "Connect WhatsApp" — FB.login() popup for Embedded Signup
  6. Meta → End-User: Returns OAuth code after user completes signup
  7. End-User → API: POST /signup/:id/callback with the OAuth code
  8. API: Processes signup — exchanges code for tokens, sets up WABA & phone number
  9. API → Your Server: Fires phone_number.connected webhook event
  10. API → End-User: Redirects to redirectUrl?onboarding_link_id=x&external_id=y&status=success
  11. 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:

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:

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


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:

Useful endpoints:

Notes:

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


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:

  1. Server-side clients set a header on the handshake: Authorization: Bearer sk_org_...
  2. 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:

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

pendingsentdeliveredread (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

  1. Create onboarding linkPOST /v1/onboarding-links with redirectUrl pointing to your app. Optionally include webhookUrl, webhookSecret, and webhookEvents to auto-register a webhook when the phone connects.
  2. Display hosted URL — Show hostedUrl to end-users so they can connect their WhatsApp number
  3. Handle redirect — Parse onboarding_link_id, external_id, and status from redirect query params
  4. Handle webhook — Listen for phone_number.connected for server-to-server reliability
  5. Fetch resultGET /v1/onboarding-links/:id to get phoneNumberId, phoneNumber, wabaId
  6. Store phone number ID — Save phoneNumberId — this is the key for all messaging operations
  7. Inspect connected account metadataGET /v1/wabas/{wabaId}/connection to read Business Portfolio, WABA, Page, dataset, catalog, and Instagram metadata
  8. Register webhookPOST /v1/outgoing-webhooks to receive message.received events (skip if you used webhookUrl in step 1)
  9. Send messagesPOST /v1/phones/{phoneId}/messages with the appropriate type
  10. Verify webhook signatures — HMAC-SHA256 with your secret for security
  11. Read conversationsGET /v1/phones/{phoneId}/conversations + messages endpoint
  12. Handle media in webhooks — Use content.downloadUrl to download the file bytes, or content.mediaUrl to fetch metadata (both require Bearer auth)
  13. Handle failures — Subscribe to message.status_updated and treat data.status: "failed" as the delivery source of truth; handle 400 MESSAGE_SEND_FAILED on sends (parse the embedded Meta error, fall back to a template message on error 131047)