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. An empty or blank value ("") is treated as not passed. | If template has a media header |
params.header_media_filename | Filename the recipient sees for DOCUMENT templates (e.g. invoice.pdf). Not enforced by the API, but strongly recommended: storage and CDN URLs (S3, CloudFront, etc.) often end in a hashed key, and WhatsApp shows that name otherwise. Ignored on other header types and when empty. | No (recommended for DOCUMENT) |
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, at most 4096 characters. | 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.
Named variables (Meta channels)
Meta also supports templates with named variables — {{customer_name}} instead of {{1}} (parameter_format: NAMED in Business Manager). For those templates, pass params.body (and params.header) as an object keyed by placeholder name:
{
"template_uuid": "TMP…",
"params": {
"body": { "customer_name": "Ana", "appointment_date": "Tuesday 10:00" }
}
}
An array is also accepted, with values in the placeholders' order of appearance. Every named placeholder must receive a value — a missing or unknown name is rejected with a 400 naming it. Get WABA Template by UUID reports parameter_format and each variable's name, and its example_payload already uses the right shape.
Named variables are only available on Meta-hosted numbers — Gupshup channels reject them with an explanatory error.
"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.
WhatsApp caps a text message at 4096 characters. A longer text is rejected with 400 before anything is sent.
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, also pass params.header_media_filename to control the filename shown to the recipient (e.g. "header_media_filename": "invoice.pdf"). We recommend always setting it: when the URL comes from storage such as S3 or a CDN, its last path segment is often a hashed key that makes a poor filename. 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 |
202 means the message was queued, not that WhatsApp accepted it. If WhatsApp rejects it when we send it (for example 130429, over the number's sending rate) or can't deliver it later, you get a whatsapp.waba.message.failed webhook with this message_uuid and the provider's errors.
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 |
400 | `text` exceeds the maximum length of 4096 characters | text is longer than 4096 characters |
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 (an empty or blank value is not an error; it is treated as not passed) |
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 (or passed it empty) |
400 | Template '...' has a IMAGE header. Use 'header_media_url' instead of 'header_params' for media headers. | Caller passed a non-empty 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 a non-empty header_media_url |
400 | Template '...' has no HEADER component. Do not pass 'header_params' or 'header_media_url'. | Template has no header at all and caller included a non-empty header or header_media_url |
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 |
Empty values never trigger these errors on their own: "header": [], "header_media_url": "" and "header_media_filename": "" are treated as not passed. Form-based integrations (Make, Zapier, n8n) can send every field and leave the unused ones empty.
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 |