Sending Messages
All message types use a single endpoint. The type field determines the payload shape.
Endpoint: POST /v1/phones/{phoneId}/messages
Required headers: Authorization: Bearer <API_KEY> and Content-Type: application/json —
nothing else.
Common Fields
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | Message type (see sections below) |
| to | string | Yes | Recipient phone number, legacy WhatsApp ID, or BSUID. When replying to a webhook, use data.contactReplyTo. |
For existing phone-based integrations, to can still be a phone number with country code. For
BSUID/usernames support, do not validate this field as digits-only. See WhatsApp Identities and
BSUID.
Phone number format: digits only with country code, no + (e.g. 5491155551234). The API
applies no normalization — the value is passed to Meta as-is, so use the contact's WhatsApp ID
(wa_id) exactly as WhatsApp reports it. When replying to a webhook, that is data.contactReplyTo.
Argentine numbers include the mobile 9 (549...); Mexican numbers include the 1 (521...).
Response
All message types return the same response:
{
"data": {
"messageId": null,
"wamid": "wamid.HBgLNTQ5MTE1NTU..."
}
}
messageId is always null in this response — correlate the send with webhooks and the messages
API through the wamid. A 200 with a wamid means Meta accepted the message, not that it
was delivered; the final state (delivered, read, or failed) arrives via the
message.status_updated webhook event. See Error Handling below.
Text Message
{
"type": "text",
"to": "5491155551234",
"text": "Hello, how can I help you?",
"previewUrl": false
}
| Field | Type | Required | Description |
|---|---|---|---|
| text | string | Yes | Message body |
| previewUrl | boolean | No | Enable URL preview (default: false) |
Media Messages (Image, Video, Audio, Document, Sticker)
Send media by URL or by pre-uploaded media ID. All five media types accept either — there is no type that requires a pre-upload.
Image by URL:
{
"type": "image",
"to": "5491155551234",
"mediaUrl": "https://example.com/photo.jpg",
"caption": "Check out this photo!"
}
Document by URL (no pre-upload needed — ideal for forwarding files you already host):
{
"type": "document",
"to": "5491155551234",
"mediaUrl": "https://example.com/coupons/coupon-1234.pdf",
"filename": "coupon-1234.pdf",
"caption": "Here is your coupon"
}
Document by pre-uploaded media ID:
{
"type": "document",
"to": "5491155551234",
"mediaId": "1234567890",
"caption": "Monthly report",
"filename": "report-march-2026.pdf"
}
When using
mediaUrl, the file is downloaded by Meta's servers, not by this API. The URL must be publicly reachable over HTTPS — no authentication, no localhost/private IPs. If the URL requires auth, upload the file first and send bymediaIdinstead.
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | "image", "video", "audio", "document", or "sticker" |
| mediaId | string | No* | WhatsApp media ID (from upload endpoint) |
| mediaUrl | string | No* | Public URL to the media file |
| caption | string | No | Caption text (image, video, document only) |
| filename | string | No | Filename (document only) |
*Either
mediaIdormediaUrlmust be provided, but not both.
Uploading Media
POST /v1/phones/{phoneId}/media with Content-Type: multipart/form-data and a file field.
Returns { "data": { "id": "1234567890" } }
The media type and constraints are derived from the file's MIME type. Supported formats and size limits:
| Type | Accepted formats | Max size |
|---|---|---|
| Image | JPEG, PNG | 5MB |
| Video | MP4, 3GPP | 16MB |
| Audio | AAC, AMR, MP3 (audio/mpeg), MP4 (audio/mp4), OGG with Opus codec (audio/ogg; codecs=opus) |
16MB |
| Document | any | 100MB |
| Sticker | WebP (static 500KB / animated 100KB) | — |
Audio contract
The API does not transcode audio — the uploaded file is validated and forwarded to WhatsApp as-is. The caller is responsible for sending a WhatsApp-compatible format:
- For a playable voice note, send OGG/Opus, mono, ≤16MB with MIME type
audio/ogg; codecs=opus. audio/webm(the default of the browserMediaRecorderin Chrome/Edge/Firefox) is not supported by WhatsApp and is rejected. Encode to OGG/Opus on the client (e.g. an Opus WASM encoder) before uploading.- Bare
audio/ogg(withoutcodecs=opus) is rejected; OGG must declare the Opus codec and be mono. - Other accepted containers (AAC, AMR, MP3, MP4/M4A) are delivered as a regular audio attachment, not a voice note.
- Common aliases are normalized automatically:
audio/opus→audio/ogg; codecs=opus,audio/ogg;codecs=opus(no space) →audio/ogg; codecs=opus,audio/mp3→audio/mpeg,audio/m4a→audio/mp4.
Template Message
{
"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" }
]
}
]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| templateName | string | Yes | Template name as registered in Meta |
| languageCode | string | Yes | Language code (e.g. "en_US", "es_AR") |
| parameters | object | No | Header, body, and button parameters |
Interactive Button Message
{
"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" },
{ "id": "btn_cancel", "title": "Cancel" }
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| interactiveType | string | Yes | Must be "button" |
| body | string | Yes | Message body text |
| header | object | No | { type: "text", text: string } (text-only headers) |
| footer | string | No | Footer text |
| buttons | array | Yes | 1-3 buttons: { id: string, title: string } (title max 20 chars) |
When the contact taps a button, you receive a message.received webhook with type: "interactive" and content.buttonId / content.buttonTitle matching the tapped button — see
the Webhooks guide.
Interactive List Message
{
"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" },
{ "id": "laptop_1", "title": "Laptop Pro", "description": "16-inch display" }
]
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
| interactiveType | string | Yes | Must be "list" |
| body | string | Yes | Message body text |
| buttonText | string | Yes | Text on the list button (max 20 chars) |
| sections | array | Yes | At least 1 section with title and rows |
When the contact picks a row, you receive a message.received webhook with type: "interactive"
and content.listId / content.listTitle matching the selected row.
CTA URL Message
{
"type": "cta_url",
"to": "5491155551234",
"bodyText": "Visit our store for exclusive deals",
"buttonText": "Open Store",
"url": "https://store.example.com",
"headerText": "Special Offer",
"footerText": "Offer expires soon"
}
Flow Message
Before sending a Flow, create and publish it through the Flows guide.
{
"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 Message
React to a previously sent or received message with an emoji.
{
"type": "reaction",
"to": "5491155551234",
"messageId": "550e8400-e29b-41d4-a716-446655440000",
"emoji": "👍"
}
messageId is the internal message UUID (the id from the messages list), not the WhatsApp wamid — the API resolves it to the wamid for you. Send an empty emoji ("") to remove a reaction you previously applied. Reactions are not standalone messages: they are aggregated onto the target message's reactions[] field (see Conversations & Messages).
Mark Message as Read
POST /v1/phones/{phoneId}/messages/{messageId}/read
messageId is the internal message UUID (the id from the messages list) of an inbound message. This marks the message read on WhatsApp and updates the local status to read. WhatsApp does not send a status webhook back when you mark an inbound message as read, so this endpoint is the only place that read state is recorded.
{ "showTypingIndicator": false }
Delete a Message
DELETE /v1/phones/{phoneId}/messages/{messageId}
Soft-deletes a message locally by its internal UUID so it no longer appears in message listings. Restricted to organization API keys (shared inbox links cannot delete messages). Returns { "data": { "success": true } }.
Delete for everyone is not possible. The WhatsApp Cloud API provides no endpoint to delete, unsend, or recall a message that was already sent — Meta does not support it. This endpoint is a local "delete for me" only: the message is hidden from your own data, but the recipient still sees it on their device.
The 24-Hour Customer Service Window
WhatsApp only allows free-form messages (text, media, interactive, CTA URL, flows) within 24 hours of the contact's last inbound message. Outside that window, only pre-approved template messages are accepted. This is Meta's standard Cloud API rule — the API forwards your send to Meta and surfaces Meta's verdict; it does not pre-validate the window locally.
- Within the window: send any message type.
- Outside the window: send a
templatemessage. When the contact replies, the window re-opens for another 24 hours and free-form messages work again. - Out-of-window sends fail with Meta error
131047("Re-engagement message"). Depending on timing, Meta either rejects synchronously (HTTP 400, see below) or accepts the message and fails it asynchronously — the failure then arrives as amessage.status_updatedwebhook withdata.status: "failed"anddata.error.code: 131047. Handle both paths.
Check the window before sending: conversation responses expose lastInboundMessageAt,
serviceWindowExpiresAt, and isServiceWindowOpen, and message webhook events carry
lastInboundMessageAt / serviceWindowExpiresAt — use them to choose between a free-form message
and a template without waiting for Meta's rejection. See
Conversations — The 24-Hour Service Window. These fields are
computed from local data; Meta remains the source of truth (Click-to-WhatsApp ad conversations
have a longer 72h window).
Error Handling
Synchronous errors (HTTP response)
When Meta rejects a send immediately, the endpoint returns HTTP 400 with code
MESSAGE_SEND_FAILED. The raw Meta error JSON is 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"
}
}
To read Meta's error code and details, strip the WhatsApp API Error: prefix and parse the
remaining JSON (error.code, error.error_data.details).
Other 400 responses you may see:
VALIDATION_ERROR— the request body failed schema validation (wrong field, missingmediaUrl/mediaId, more than 3 buttons, etc.). Nothing was sent to Meta.MESSAGE_SEND_FAILEDwith message"WhatsApp did not accept the message (no message id returned)..."— Meta answered without awamid; the recipient may be unreachable.
Asynchronous failures (webhook)
A 200 response with a wamid means Meta accepted the message — not that it was delivered.
Some failures only surface afterwards (window expired, undeliverable recipient, spam limits).
They arrive as message.status_updated events with data.status: "failed" and the raw Meta
error in data.error:
{
"event": "message.status_updated",
"data": {
"status": "failed",
"wamid": "wamid.HBgLNTQ5MTE1NTU...",
"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."
}
}
}
}
If delivery matters to your integration, always subscribe to message.status_updated and treat
data.status: "failed" as the source of truth.
Common Meta Error Codes
| Code | Meaning | Typical fix |
|---|---|---|
| 131047 | Outside the 24-hour customer service window | Send a template message instead |
| 131026 | Message undeliverable (recipient not on WhatsApp, or hasn't accepted new ToS) | Verify the number is an active WhatsApp user |
| 131056 | Pair rate limit — too many messages to the same recipient in a short period | Throttle per-recipient sends; retry after a pause |
| 131048 | Spam rate limit — sending paused due to quality | Reduce volume; check quality rating via phone health |
| 131049 | Meta chose not to deliver (per-user marketing message limits) | Do not retry immediately |
| 100 | Invalid parameter (malformed to, bad template params, unreachable mediaUrl...) |
Fix the request payload |
| 133010 | Phone not registered on Cloud API | Re-register via POST /v1/phones/{phoneId}/registration |
| 80007 | WABA rate limit reached | Slow down overall throughput |
Rate Limits
The API adds no rate limiting, queueing, or per-recipient locking of its own — requests are
forwarded to Meta as they arrive, and there is no 409-style error when two messages target the
same recipient simultaneously. Meta's own limits are the ones that apply:
- Throughput: a registered phone number can send up to ~80 messages per second by default (Meta auto-scales this based on quality).
- Pair rate limit: rapid bursts to the same recipient are rejected by Meta with error
131056. - Messaging limit tier: caps how many unique contacts you can open business-initiated
(template) conversations with per 24 hours (TIER_250, TIER_1K, ...). Check it via
GET /v1/phones/{phoneId}/health.
Meta rate-limit rejections surface like any other Meta error: synchronously as a 400
MESSAGE_SEND_FAILED or asynchronously as a failed status update. If your automation can burst
multiple messages to one recipient, add a small delay between consecutive sends to the same
number.