/v1/messagesbetaSend a message
- Needs
- Send WhatsApp messages; manage templates, quick messages and WhatsApp short links
(messaging:write) - Plan
- Any plan with API access
- Limits
- 60 a minute per key · 20 an hour · 100 a day · 3 per contact a day
- Dry run
- Yes — a full preview with ?dry_run=true
- Undo
- Cannot be undone: the customer receives the message.
Sends one free-form message to a customer: text, media by public https link, a location, buttons, a list, a link button or a reaction. The customer receives it and it cannot be undone.
Say who receives it with exactly one of:
| Target | Channels | Goes into |
|---|---|---|
conversation_id | any | that conversation |
contact_id | any | the contact's conversation |
phone (the customer's number, international format such as +15555550123) | WhatsApp only | the conversation of the contact with that number |
With contact_id or phone you may also choose where it goes out from: channel_id, or from_phone (one of your connected WhatsApp numbers, in any format). When the contact has conversations on several channels and you name none, the one with an open reply window is used; when several are open you must choose (400, reason: channel_ambiguous, with the choices). Web chat and custom channels have no reply window, so they are never picked over a WhatsApp, Instagram, Messenger or TikTok conversation: name them with channel_id (they are picked by themselves only when the contact has no other kind of conversation). With conversation_id the conversation's own channel is used, so neither is accepted.
A free-form message needs an open reply window where the channel has one (on WhatsApp, 24 hours after the customer's last message). The message always goes into the customer's existing conversation: when there is none on that channel (the customer never wrote there, or no contact has that number) or its window is closed, the answer is 409 with reason: window_closed and nothing is sent. Send an approved template with Send a WhatsApp template instead (contact_id is in the answer when the contact is known). A contact with do-not-disturb on is not messaged by contact_id or phone (409, reason: contact_dnd).
The key's staff member must be allowed to reply in that conversation. Customer messages count against the workspace send limits. Try it with ?dry_run=true: every check runs and the answer shows the conversation, contact, channel and window it resolved to, but nothing is sent.
Try it
Protects against doing it twice if you retry: a retry with the same key gets the first answer back instead of running again.
Code and response
curl -X POST 'https://mcp.wa-api.cloud/v1/messages?dry_run=true' \
-H "Authorization: Bearer $API_KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"phone": "+15555550123",
"from_phone": "+1 555-555-0100",
"type": "text",
"text": "Your order #1001 is ready for pickup."
}'The code reads your key from $API_KEY.
Body
| Field | Type | What it is |
|---|---|---|
| conversation_id | string | Target: reply in this conversation. Takes neither channel_id nor from_phone.pattern ^\d{1,19}$Signed in? Pick one from your data with “My data”. |
| contact_id | string | Target: this contact, in their existing conversation.pattern ^\d{1,19}$Signed in? Pick one from your data with “My data”. |
| phone | string | Target: the customer's WhatsApp number in international format, e.g. +15555550123 (spaces and dashes are fine). WhatsApp only; the number must belong to one of your contacts.3–32 characters |
| channel_id | string | With contact_id or phone: send on this channel. Needed when the contact has open conversations on several channels.pattern ^\d{1,19}$Signed in? Pick one from your data with “My data”. |
| from_phone | string | With contact_id or phone, instead of channel_id: one of your connected WhatsApp numbers to send from, in any format.3–32 characters |
| typerequired | string | What to send: text, image, video, audio, document, sticker, location, interactive or reaction. Send exactly the one matching block.one of: text, image, video, audio, document, sticker, location, interactive, reaction, system |
| text | string | type text: the message (1–4096 characters).1–4096 characters |
| reply_to_message_id | string | Quote this message of the conversation (a message id from its message list).pattern ^\d{1,19}$ |
| media | object | type image, video, audio, document or sticker: url, a public https link to the file, with an optional caption (not for audio or stickers) and a filename for documents. The platform downloads the link when you send (it must answer directly, without a login or a redirect, within 60 seconds) and checks the file by its content: image JPEG or PNG up to 5 MB (WebP, GIF, HEIC and other images are converted), video MP4 or 3GP up to 16 MB, audio MP3, AAC, AMR, OGG or M4A up to 16 MB (other audio is converted), document PDF, Word, Excel or text up to 100 MB, sticker WebP up to 500 KB. The link must name its host on the default port (no IP address, port, user name, backslash or control character); a host that is private or internal, or that does not resolve, is refused (reason: url_refused, private_address or dns_failed). To send a file from your own system without hosting it, use Send a file in a conversation (POST /v1/conversations/{conversation_id}/files). |
| urlrequired | string | Public https URL of the file (the platform downloads it).0–2000 characters |
| caption | string | Caption (image, video, document).0–1024 characters |
| filename | string | document: file name shown.0–200 characters |
| location | object | type location: latitude, longitude, and an optional name and address. |
| latituderequired | number | Latitude.-90–90 |
| longituderequired | number | Longitude.-180–180 |
| name | string | Place name.0–255 characters |
| address | string | Address.0–255 characters |
| interactive | object | type interactive: kind buttons (1–3 quick replies), list (a menu of up to 10 rows) or cta_url (one link button). |
| kindrequired | string | buttons (1–3 quick replies), list (a menu), cta_url (one link button).one of: buttons, list, cta_url |
| bodyrequired | string | Message text (≤ 1024).1–1024 characters |
| header | string | Header text (≤ 60).0–60 characters |
| footer | string | Footer text (≤ 60).0–60 characters |
| buttons | array of object | kind buttons: 1–3 buttons, unique ids and titles.1–3 items |
| idrequired | string | Reply id you get back when tapped.1–256 characters |
| titlerequired | string | Button text (≤ 20).1–20 characters |
| list_button | string | kind list: the text of the button that opens the menu (≤ 20).1–20 characters |
| sections | array of object | kind list: 1–10 sections, 10 rows in total at most.1–10 items |
| title | string | Section title (≤ 24; needed with several sections).0–24 characters |
| rowsrequired | array of object | Rows.1–10 items |
| idrequired | string | Row id you get back.1–200 characters |
| titlerequired | string | Row title (≤ 24).1–24 characters |
| description | string | Row description (≤ 72).0–72 characters |
| url | string | kind cta_url: https link.uri · 0–2000 characters |
| url_text | string | kind cta_url: button text (≤ 20).1–20 characters |
| reaction | object | type reaction: the message id to react to and one emoji ("" removes the reaction). |
| message_idrequired | string | Message to react to (GET /v1/conversations/{conversation_id}/messages id).pattern ^\d{1,19}$ |
| emojirequired | string | One emoji; "" removes the reaction.0–16 characters |
Headers
| Field | Type | What it is |
|---|---|---|
| Authorizationrequired | header | Bearer $API_KEY — your API key. |
| Api-Version | header | The API version to use, e.g. 2026-10-01. Default: the version your key is pinned to.one of: 2026-10-01 · pattern ^\d{4}-\d{2}-\d{2}$ |
| Idempotency-Keyrequired | header | Required here. Any unique string (8–128 characters), e.g. your order id plus the step. Kept 24 hours. Not needed with dry_run=true.pattern ^[A-Za-z0-9._:-]+$ · 8–128 characters |
Response 202
Accepted for delivery (or the dry-run check), with the conversation, contact and channel it resolved to. Delivery and read receipts show as the message's `status` in the message list.
| Field | Type | What it is |
|---|---|---|
| message_idrequired | string or null | |
| statusrequired | string | queued (accepted for delivery), created (system line) or dry_run. |
| typerequired | string | |
| conversation_idrequired | string | The conversation it went into (resolved from contactId / phone when those were given). |
| contact_idrequired | string or null | The customer it went to. |
| channelrequired | object | |
| idrequired | string | |
| namerequired | string or null | |
| typerequired | string | |
| windowrequired | object | |
| appliesrequired | boolean | false for channels without a window (web chat, custom) and for system messages. |
| openrequired | boolean or null | null when the window does not apply. |
| kindrequired | string or null | service (24 h), free_entry (72 h after an ad), standard, tiktok (48 h), web. |
| closes_atrequired | string or null | Open window: when it closes. |
| closed_atrequired | string or null | Closed window: when it closed (null = the customer never wrote on this channel, or unknown). |
| visible_to_customerrequired | boolean | |
| dry_run | boolean | true when this was a dry run: every check ran and nothing changed. |
Errors
Errors are application/problem+json. Branch on code.
| Status | Code | When |
|---|---|---|
| 400 | invalid_input | A field is missing or has the wrong format. |
| 401 | unauthenticated | The Authorization header is missing, the key is unknown, expired or revoked. |
| 403 | entitlement_required | The workspace's plan does not include API access ( |
| 403 | forbidden | The key's staff member may not reply in the conversation: it is closed, or assigned to someone else ( |
| 403 | insufficient_scope | The key does not have the permission this operation needs. |
| 404 | not_found | The conversation, contact or channel is not in your workspace, or |
| 409 | conflict | No open reply window ( |
| 429 | rate_limited | The key or workspace went over its rate limit. Wait for |
| 503 | upstream_unavailable | A service behind the API is briefly unavailable. Safe to retry with backoff. |
| 504 | timeout | The change did not finish in time. Retry with the same Idempotency-Key: it never runs twice. |
Examples
Message a customer by WhatsApp number, from one of your numbers
Request body
{
"phone": "+15555550123",
"from_phone": "+1 555-555-0100",
"type": "text",
"text": "Your order #1001 is ready for pickup."
}Response 202
{
"message_id": "5501239",
"status": "queued",
"type": "text",
"conversation_id": "77410",
"contact_id": "48213",
"channel": {
"id": "301",
"name": "Main WhatsApp",
"type": "whatsapp"
},
"window": {
"applies": true,
"open": true,
"kind": "service",
"closes_at": "2026-09-25T09:12:00.000Z",
"closed_at": null
},
"visible_to_customer": true
}Check a message to a contact on one channel (dry run)
Request body
{
"contact_id": "48213",
"channel_id": "301",
"type": "text",
"text": "Thanks! Your refund was issued today."
}Response 202
{
"message_id": null,
"status": "dry_run",
"type": "text",
"conversation_id": "77410",
"contact_id": "48213",
"channel": {
"id": "301",
"name": "Main WhatsApp",
"type": "whatsapp"
},
"window": {
"applies": true,
"open": true,
"kind": "service",
"closes_at": "2026-09-25T09:12:00.000Z",
"closed_at": null
},
"visible_to_customer": true,
"dry_run": true
}Operation path
The same operation is also at POST /v1/ops/inbox_send_message, with every field in the JSON body.