Send WABA Message
Automate this endpoint with an AI agent using the 2chat-whatsapp-waba skill:
npx skills add 2ChatCo/agent-skills -s 2chat-whatsapp-waba
Send a message via WhatsApp Business API (WABA). You can send either a template message (for starting a conversation or after the 24-hour window) or a session message (plain text within an active conversation).
Requires a WABA channel connected to your 2Chat account. Get template UUIDs from the Get WABA Templates endpoint, and the exact values a template expects from Get WABA Template by UUID.
Request body (common fields)
| Field | Description | Required |
|---|---|---|
to_number | Recipient number in E.164 format (e.g. +595981048477). | Yes |
from_number | Your WABA number in E.164 format (e.g. +5215512345432). | Yes |
For template messages, also send:
| Field | Description | Required |
|---|---|---|
template_uuid | Template UUID from Get WABA Templates. The template must be in APPROVED status. | Yes |
params | Template variable values. Required for template messages; use an empty object {} (or e.g. {"body": []}) if the template has no variables. | Yes |
params.body | Array of strings replacing {{1}}, {{2}}, … in the template body. The number of values must match the template exactly — see Template parameter values. | If template has body variables |
params.header | Array of strings replacing {{1}}, {{2}}, … in a TEXT header. Only for templates whose header is TEXT with placeholders. | If applicable |
params.header_media_url | Public URL to the image, video, or document for templates with an IMAGE / VIDEO / DOCUMENT header. Must be http or https and publicly downloadable. 2Chat fetches the file and re-hosts it on its own storage before forwarding to WhatsApp, so short-lived signed URLs are fine as long as they are reachable at send time. | If template has a media header |
params.header_media_filename | Optional filename hint for DOCUMENT templates (e.g. invoice.pdf). | No |
params.button | Array with the value for a dynamic URL button — the part that replaces the {{1}} placeholder in the button's URL. E.g. a template button https://shop.example.com/track/{{1}} with params.button: ["a1b2c3"] resolves to https://shop.example.com/track/a1b2c3. | If the template has a dynamic URL button |
For session messages (within 24-hour window), send:
| Field | Description | Required |
|---|---|---|
text | Plain text message. | Yes |
You must send either a template message (template_uuid and params; params is required even if empty) or a session message (text), not both in the same request.
Template parameter values
Every params.* array is validated against the approved template before the message is queued. Getting these rules wrong is the most common cause of a 400.
Call Get WABA Template by UUID to obtain the exact list of variables a template expects — including how many values each array takes and a ready-to-copy example_payload.
Values are positional, not keyed
params.body, params.header and params.button are arrays. Values are applied in order to the template's placeholders — they are not a map keyed by placeholder number.
"params": { "body": ["Maria", "TRK-98452"] }
The first value replaces the first placeholder, the second replaces the second, and so on.
The count must match exactly
The expected count is the number of distinct {{N}} placeholders in the component. Passing too few or too many values is rejected:
{
"error": true,
"error_message": "Payload is invalid: Template 'order_shipped' expects 2 body param(s) but 1 were provided"
}
The same rule applies to params.header (TEXT headers) and params.button (dynamic URL buttons). If a template has no variables at all, send "params": {}.
Character and length restrictions
WhatsApp rejects template parameters containing certain characters (Meta error 132018), so 2Chat validates them up front:
| Rule | Applies to | Rejected with |
|---|---|---|
| Maximum 1024 characters per value | params.body, params.header, params.button | `params.body[0]` exceeds the maximum length of 1024 characters |
No line breaks (\n) | all param arrays | `params.body[0]` contains invalid characters: line breaks, tabs, and more than 4 consecutive spaces are not allowed in template parameters |
No tabs (\t) | all param arrays | same as above |
| No runs of 5 or more consecutive spaces | all param arrays | same as above |
Values are checked by position, so the error message points at the exact offending index.
Meta additionally caps a TEXT header variable at 60 characters. 2Chat forwards longer values, but Meta will reject the send. Get WABA Template by UUID reports the applicable max_length per variable.
Which buttons take a value
Only dynamic URL buttons — a call-to-action button whose URL contains a {{1}} placeholder — consume a value from params.button:
| Button type | Needs a value in params.button? |
|---|---|
URL with a {{N}} placeholder | Yes — one value |
| URL without a placeholder (static link) | No |
| Quick reply | No |
| Phone number | No |
OTP button of an AUTHENTICATION template | No — see below |
Only one dynamic URL button per template is supported. Templates with more are rejected with Template '...' has N dynamic URL buttons; only one is supported.
Authentication (OTP) templates
For an AUTHENTICATION template, pass the verification code in params.body and omit params.button — the OTP button reuses the body value automatically:
{
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["472913"]
}
}
Omitting params.body on an authentication template is rejected with Template '...' is an authentication template. Pass the verification code in 'params.body'; the button reuses it. Passing the code explicitly in params.button as well is still accepted for backwards compatibility.
Only APPROVED templates can be sent
A template in PENDING, REJECTED or FAILED status is rejected with Template '...' is not approved (status: PENDING). Only APPROVED templates can be sent. Check status via Get WABA Templates before sending.
Send a template message
Use when starting a conversation or when the 24-hour session has expired. Replace template placeholders with values in params.body (and optionally params.header or params.button).
- cURL
- Python
- JavaScript
curl --request POST \
--url 'https://api.p.2chat.io/open/waba/send-message' \
--header 'Content-Type: application/json' \
--header 'X-User-API-Key: your_api_key_here' \
--data '{
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["Maria", "TRK-98452"]
}
}'
import requests
import json
url = "https://api.p.2chat.io/open/waba/send-message"
payload = {
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["Maria", "TRK-98452"]
}
}
headers = {
"Content-Type": "application/json",
"X-User-API-Key": "your_api_key_here"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
const axios = require('axios');
axios.post('https://api.p.2chat.io/open/waba/send-message', {
to_number: '+595981048477',
from_number: '+5215512345432',
template_uuid: 'TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23',
params: {
body: ['Maria', 'TRK-98452']
}
}, {
headers: {
'Content-Type': 'application/json',
'X-User-API-Key': 'your_api_key_here'
}
})
.then(response => console.log(response.data))
.catch(error => console.error(error));
Send a template with a media header
Use this shape when the template's header is IMAGE, VIDEO, or DOCUMENT. Pass the public URL of the media in params.header_media_url. The type (image / video / document) is derived from the template — you do not need to specify it.
- cURL
- Python
- JavaScript
curl --request POST \
--url 'https://api.p.2chat.io/open/waba/send-message' \
--header 'Content-Type: application/json' \
--header 'X-User-API-Key: your_api_key_here' \
--data '{
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["Maria"],
"header_media_url": "https://example.com/promo.png"
}
}'
import requests
url = "https://api.p.2chat.io/open/waba/send-message"
payload = {
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["Maria"],
"header_media_url": "https://example.com/promo.png"
}
}
headers = {
"Content-Type": "application/json",
"X-User-API-Key": "your_api_key_here"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
const axios = require('axios');
axios.post('https://api.p.2chat.io/open/waba/send-message', {
to_number: '+595981048477',
from_number: '+5215512345432',
template_uuid: 'TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23',
params: {
body: ['Maria'],
header_media_url: 'https://example.com/promo.png'
}
}, {
headers: {
'Content-Type': 'application/json',
'X-User-API-Key': 'your_api_key_here'
}
})
.then(response => console.log(response.data))
.catch(error => console.error(error));
For DOCUMENT templates, you can also pass params.header_media_filename to control the filename shown to the recipient (e.g. "header_media_filename": "invoice.pdf"). For VIDEO templates, the request shape is identical to IMAGE — only params.header_media_url is needed.
- The URL must use
httporhttpsand resolve to a publicly reachable host. URLs pointing to private ranges (loopback, link-local, RFC1918) are rejected. - 2Chat downloads the file and re-hosts it on its own storage before sending to WhatsApp, so hot-link-protected CDNs work as long as the file can be fetched server-side.
- The same hosted asset is reused across sends, so passing identical URLs is cheap.
Send a template with a dynamic URL button
Use this shape when the template has a dynamic URL button — a call-to-action button whose URL ends in a {{1}} placeholder (e.g. https://shop.example.com/track/{{1}}). Pass the value that replaces it in params.button. The example below resolves the button to https://shop.example.com/track/a1b2c3.
- cURL
- Python
- JavaScript
curl --request POST \
--url 'https://api.p.2chat.io/open/waba/send-message' \
--header 'Content-Type: application/json' \
--header 'X-User-API-Key: your_api_key_here' \
--data '{
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["Maria"],
"button": ["a1b2c3"]
}
}'
import requests
url = "https://api.p.2chat.io/open/waba/send-message"
payload = {
"to_number": "+595981048477",
"from_number": "+5215512345432",
"template_uuid": "TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23",
"params": {
"body": ["Maria"],
"button": ["a1b2c3"]
}
}
headers = {
"Content-Type": "application/json",
"X-User-API-Key": "your_api_key_here"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
const axios = require('axios');
axios.post('https://api.p.2chat.io/open/waba/send-message', {
to_number: '+595981048477',
from_number: '+5215512345432',
template_uuid: 'TMP1b44a079-c75c-4403-bc8f-a75c4ce5cd23',
params: {
body: ['Maria'],
button: ['a1b2c3']
}
}, {
headers: {
'Content-Type': 'application/json',
'X-User-API-Key': 'your_api_key_here'
}
})
.then(response => console.log(response.data))
.catch(error => console.error(error));
If the template's URL button contains a placeholder and you omit params.button, the request is rejected with 400 — the link would otherwise be delivered unresolved. The number of values in params.button must match the button's placeholders (currently one {{1}}).
Send a session message
Use when replying within the 24-hour customer session. Send only text (no template_uuid or params).
- cURL
- Python
- JavaScript
curl --request POST \
--url 'https://api.p.2chat.io/open/waba/send-message' \
--header 'Content-Type: application/json' \
--header 'X-User-API-Key: your_api_key_here' \
--data '{
"to_number": "+595981048477",
"from_number": "+5215512345432",
"text": "Hi, how can we help you today?"
}'
import requests
import json
url = "https://api.p.2chat.io/open/waba/send-message"
payload = {
"to_number": "+595981048477",
"from_number": "+5215512345432",
"text": "Hi, how can we help you today?"
}
headers = {
"Content-Type": "application/json",
"X-User-API-Key": "your_api_key_here"
}
response = requests.post(url, json=payload, headers=headers)
print(response.json())
const axios = require('axios');
axios.post('https://api.p.2chat.io/open/waba/send-message', {
to_number: '+595981048477',
from_number: '+5215512345432',
text: 'Hi, how can we help you today?'
}, {
headers: {
'Content-Type': 'application/json',
'X-User-API-Key': 'your_api_key_here'
}
})
.then(response => console.log(response.data))
.catch(error => console.error(error));
Response
On success the endpoint returns 202 Accepted — the message passed validation and was queued for delivery:
{
"success": true,
"batched": true,
"message_uuid": "MSG126d9055-9dad-415f-b934-ff909773a8ef"
}
| Field | Description |
|---|---|
success | true when the message was accepted |
batched | true when the message was successfully queued for sending |
message_uuid | Internal UUID of the message for tracking |
Error responses
Errors return an HTTP 4xx status and a JSON body in the shape:
{
"error": true,
"error_code": "UNKNOWN_ERROR",
"error_message": "Payload is invalid: Template 'order_shipped' has a IMAGE header. You must pass 'header_media_url' with a public URL to the media."
}
| Field | Description |
|---|---|
error | Always true on failure |
error_code | Machine-readable code. WABA_WINDOW_CLOSED when the 24-hour window is closed; UNKNOWN_ERROR for the payload validation errors below |
error_message | Human-readable explanation. Payload validation errors are prefixed with Payload is invalid: |
Request shape
| Status | error_message | When |
|---|---|---|
400 | from_number is required | Missing required field |
400 | to_number is required | Missing required field |
400 | template_uuid or text or interactive is required | None is present |
400 | template_uuid and text or interactive cannot be used together | More than one is present |
400 | `params` must be an object | params is not a JSON object |
400 | `text` must be a string | text has the wrong type |
Template parameter values
| Status | error_message | When |
|---|---|---|
400 | Template '...' expects N body param(s) but M were provided | params.body length does not match the template's placeholders |
400 | Template '...' TEXT header expects N param(s) but M were provided | params.header length does not match the TEXT header's placeholders |
400 | Template '...' expects N URL-button param(s) but M were provided | params.button length does not match the template's dynamic URL buttons |
400 | `params.body` must be an array | A params.* value is not an array |
400 | `params.body[0]` exceeds the maximum length of 1024 characters | A parameter value is too long |
400 | `params.body[0]` contains invalid characters: line breaks, tabs, and more than 4 consecutive spaces … | A parameter value contains characters Meta rejects (error 132018) |
400 | Template '...' is not approved (status: PENDING). Only APPROVED templates can be sent. | Template is not in APPROVED status |
400 | Template '...' is an authentication template. Pass the verification code in 'params.body'; the button reuses it. | AUTHENTICATION template sent without params.body |
400 | Template '...' has N dynamic URL buttons; only one is supported. | Template has more than one placeholder URL button |
400 | template_uuid is not valid | Template does not exist or does not belong to your account |
Header media
| Status | error_message | When |
|---|---|---|
400 | `params.header_media_url` must be a valid public URL | URL has invalid syntax |
400 | `params.header_media_url` must be a string URL | URL has the wrong type |
400 | Template '...' has a IMAGE header. You must pass 'header_media_url' with a public URL to the media. | Template expects media, caller did not pass header_media_url |
400 | Template '...' has a IMAGE header. Use 'header_media_url' instead of 'header_params' for media headers. | Caller passed params.header for a media header |
400 | Template '...' has a TEXT header. 'header_media_url' is only valid for IMAGE/VIDEO/DOCUMENT headers. | Template header is TEXT and caller included header_media_url |
400 | Template '...' has no HEADER component. Do not pass 'header_params' or 'header_media_url'. | Template has no header at all |
400 | Could not fetch the media URL provided in 'params.header_media_url' ... | URL unreachable, blocked by the SSRF guard (private/loopback host), or rejected by the scheme allowlist |
Channel and delivery
| Status | error_message | When |
|---|---|---|
404 | Resource Not Found: from_number does not exist on your account | Number not found or marked for deletion |
410 | Source number is not connected to 2Chat. | Channel is in a non-connected state |
422 | The 24-hour customer service window for this contact is closed. … | A free-form text send was attempted outside the window. Send a template instead, or check first with Conversation Window. error_code is WABA_WINDOW_CLOSED |
402 | Please upgrade your account. | Insufficient WABA credit to send the message |