Send a message
POST
/v1/messagesSends a WhatsApp or SMS message with a template of the account to one number. An SMS may carry an own text instead, when the account allows it.
202 Accepted means the message passed every check, its price is fixed and reserved, and it is queued (status is queued). Its statuses follow as webhooks and in GET /messages/{id}. A refused request creates no message and costs nothing.
Send an Idempotency-Key header to retry safely: a repeat with the same key and body within 24 hours answers the first message with the header Idempotent-Replayed: true. A test key sends through the sandbox: free, price null, test true, at most 1000 messages a day.
Guides: https://docs.ace-send.com/guide/sending, https://docs.ace-send.com/guide/sms.
API key permission: send · Limit: 50 requests a second per key
Headers
| Field | Type | Required | Description |
|---|---|---|---|
Idempotency-Key | string | 1–64 printable ASCII characters. A repeat with the same key and body within 24 hours answers the same message; another body — 409 idempotency_conflict. Pattern: ^[!-~]{1,64}$. |
Request body
A message with a template (template, language, variables) or, for an SMS, with an own text — exactly one of the two: both answer text prohibited, neither answers template required.
| Field | Type | Required | Description |
|---|---|---|---|
to | string | yes | The number in E.164, for example +5521998120937. Up to 32 characters. |
template | string | The name of an approved template of the channel (GET /templates); or text, never both. Up to 64 characters. | |
language | string | A language of the template; not with text. Up to 16 characters. | |
variables | object | Values of the variables by key; a missing one takes the fallback of the template in the language. Not with text. Keys map to string values. | |
reference | string, nullable | Your own id of the message (up to 64 characters), returned with it and searchable. Up to 64 characters. | |
channel | string | One of: whatsapp, sms. | |
text | string, nullable | An own SMS text instead of a template (channel sms) when the account may send one (otherwise 403 free_text_not_allowed); split into segments as any SMS. Not with template. Up to 10000 characters. |
Request
WhatsApp template
bash
curl -X POST "https://api.ace-send.com/v1/messages" \
-H "Authorization: Bearer ak_live_YOUR_API_KEY" \
-H "Idempotency-Key: crm-42-welcome" \
-H "Content-Type: application/json" \
-d '{
"to": "+5521998120937",
"template": "welcome_bonus",
"language": "pt",
"variables": {
"name": "Ana",
"promo_code": "BONUS10"
},
"reference": "crm-42"
}'js
const response = await fetch('https://api.ace-send.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ACE_SEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'crm-42-welcome',
},
body: JSON.stringify({
"to": "+5521998120937",
"template": "welcome_bonus",
"language": "pt",
"variables": {
"name": "Ana",
"promo_code": "BONUS10"
},
"reference": "crm-42"
}),
})
const result = await response.json()
if (!response.ok) {
// application/problem+json: result.code is stable, result.title is for people
throw new Error(`${result.code}: ${result.title}`)
}
console.log(result)python
import os
import requests
headers = {
"Authorization": f"Bearer {os.environ['ACE_SEND_API_KEY']}",
"Idempotency-Key": "crm-42-welcome",
}
response = requests.post(
"https://api.ace-send.com/v1/messages",
headers=headers,
json={
"to": "+5521998120937",
"template": "welcome_bonus",
"language": "pt",
"variables": {
"name": "Ana",
"promo_code": "BONUS10",
},
"reference": "crm-42",
},
timeout=10,
)
result = response.json()
if not response.ok:
# application/problem+json: result["code"] is stable, result["title"] is for people
raise RuntimeError(f"{result['code']}: {result['title']}")
print(result)php
<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client(['base_uri' => 'https://api.ace-send.com/v1/']);
$response = $client->request('POST', 'messages', [
'headers' => [
'Authorization' => 'Bearer '.getenv('ACE_SEND_API_KEY'),
'Idempotency-Key' => 'crm-42-welcome',
],
'json' => [
'to' => '+5521998120937',
'template' => 'welcome_bonus',
'language' => 'pt',
'variables' => [
'name' => 'Ana',
'promo_code' => 'BONUS10',
],
'reference' => 'crm-42',
],
'http_errors' => false,
]);
$result = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() >= 400) {
// application/problem+json: $result['code'] is stable, $result['title'] is for people
throw new RuntimeException($result['code'].': '.$result['title']);
}
print_r($result);SMS template
bash
curl -X POST "https://api.ace-send.com/v1/messages" \
-H "Authorization: Bearer ak_live_YOUR_API_KEY" \
-H "Idempotency-Key: crm-42-welcome" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"to": "+5511912345678",
"template": "deposit_reminder",
"language": "en",
"variables": {
"name": "Ann",
"amount": "$50.00"
}
}'js
const response = await fetch('https://api.ace-send.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ACE_SEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'crm-42-welcome',
},
body: JSON.stringify({
"channel": "sms",
"to": "+5511912345678",
"template": "deposit_reminder",
"language": "en",
"variables": {
"name": "Ann",
"amount": "$50.00"
}
}),
})
const result = await response.json()
if (!response.ok) {
// application/problem+json: result.code is stable, result.title is for people
throw new Error(`${result.code}: ${result.title}`)
}
console.log(result)python
import os
import requests
headers = {
"Authorization": f"Bearer {os.environ['ACE_SEND_API_KEY']}",
"Idempotency-Key": "crm-42-welcome",
}
response = requests.post(
"https://api.ace-send.com/v1/messages",
headers=headers,
json={
"channel": "sms",
"to": "+5511912345678",
"template": "deposit_reminder",
"language": "en",
"variables": {
"name": "Ann",
"amount": "$50.00",
},
},
timeout=10,
)
result = response.json()
if not response.ok:
# application/problem+json: result["code"] is stable, result["title"] is for people
raise RuntimeError(f"{result['code']}: {result['title']}")
print(result)php
<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client(['base_uri' => 'https://api.ace-send.com/v1/']);
$response = $client->request('POST', 'messages', [
'headers' => [
'Authorization' => 'Bearer '.getenv('ACE_SEND_API_KEY'),
'Idempotency-Key' => 'crm-42-welcome',
],
'json' => [
'channel' => 'sms',
'to' => '+5511912345678',
'template' => 'deposit_reminder',
'language' => 'en',
'variables' => [
'name' => 'Ann',
'amount' => '$50.00',
],
],
'http_errors' => false,
]);
$result = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() >= 400) {
// application/problem+json: $result['code'] is stable, $result['title'] is for people
throw new RuntimeException($result['code'].': '.$result['title']);
}
print_r($result);SMS with an own text
bash
curl -X POST "https://api.ace-send.com/v1/messages" \
-H "Authorization: Bearer ak_live_YOUR_API_KEY" \
-H "Idempotency-Key: crm-42-welcome" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"to": "+5511912345678",
"text": "Your ACE SEND code is 482913. It expires in 10 minutes.",
"reference": "otp-7781"
}'js
const response = await fetch('https://api.ace-send.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ACE_SEND_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'crm-42-welcome',
},
body: JSON.stringify({
"channel": "sms",
"to": "+5511912345678",
"text": "Your ACE SEND code is 482913. It expires in 10 minutes.",
"reference": "otp-7781"
}),
})
const result = await response.json()
if (!response.ok) {
// application/problem+json: result.code is stable, result.title is for people
throw new Error(`${result.code}: ${result.title}`)
}
console.log(result)python
import os
import requests
headers = {
"Authorization": f"Bearer {os.environ['ACE_SEND_API_KEY']}",
"Idempotency-Key": "crm-42-welcome",
}
response = requests.post(
"https://api.ace-send.com/v1/messages",
headers=headers,
json={
"channel": "sms",
"to": "+5511912345678",
"text": "Your ACE SEND code is 482913. It expires in 10 minutes.",
"reference": "otp-7781",
},
timeout=10,
)
result = response.json()
if not response.ok:
# application/problem+json: result["code"] is stable, result["title"] is for people
raise RuntimeError(f"{result['code']}: {result['title']}")
print(result)php
<?php
// composer require guzzlehttp/guzzle
$client = new GuzzleHttp\Client(['base_uri' => 'https://api.ace-send.com/v1/']);
$response = $client->request('POST', 'messages', [
'headers' => [
'Authorization' => 'Bearer '.getenv('ACE_SEND_API_KEY'),
'Idempotency-Key' => 'crm-42-welcome',
],
'json' => [
'channel' => 'sms',
'to' => '+5511912345678',
'text' => 'Your ACE SEND code is 482913. It expires in 10 minutes.',
'reference' => 'otp-7781',
],
'http_errors' => false,
]);
$result = json_decode((string) $response->getBody(), true);
if ($response->getStatusCode() >= 400) {
// application/problem+json: $result['code'] is stable, $result['title'] is for people
throw new RuntimeException($result['code'].': '.$result['title']);
}
print_r($result);Response
202 Accepted
| Field | Type | Description |
|---|---|---|
data | Message |
json
{
"data": {
"id": "msg_CgdWeHQN4sPaZnXfh7kP5",
"status": "queued",
"to": "+5521998120937",
"from": null,
"country": "BR",
"channel": "whatsapp",
"template": {
"name": "welcome_bonus",
"version": 3
},
"language": "pt",
"reference": "crm-42",
"text": "Olá Ana! Seu bônus BONUS10 já está ativo.",
"text_masked": false,
"segments": 1,
"encoding": null,
"price": {
"amount": "0.062500",
"currency": "USD"
},
"error": null,
"test": false,
"campaign": null,
"created_at": "2026-11-02T12:00:00+00:00",
"updated_at": "2026-11-02T12:00:00+00:00",
"events": null
}
}json
{
"data": {
"id": "msg_CgdWeHQNnp4VN7KkfNqju",
"status": "queued",
"to": "+5511912345678",
"from": "ACESEND",
"country": "BR",
"channel": "sms",
"template": null,
"language": null,
"reference": "otp-7781",
"text": "Your ACE SEND code is 482913. It expires in 10 minutes.",
"text_masked": false,
"segments": 1,
"encoding": "gsm7",
"price": {
"amount": "0.018000",
"currency": "USD"
},
"error": null,
"test": false,
"campaign": null,
"created_at": "2026-11-02T12:00:00+00:00",
"updated_at": "2026-11-02T12:00:00+00:00",
"events": null
}
}Objects
Message object
| Field | Type | Description |
|---|---|---|
id | string | The message, msg_…. |
status | string | One of: accepted, queued, submitted, sent, delivered, read, rejected, failed, expired, cancelled. |
to | string | The number in E.164. |
from | string, nullable | The sender name of an SMS; null for WhatsApp (its number is not shown) and for a test SMS. |
country | string | ISO 3166-1 alpha-2 of the number. |
channel | string | One of: whatsapp, sms. |
template | object, nullable | The template and the number of the version the message was sent with; null for an own SMS text. |
template.name | string | |
template.version | integer | |
language | string, nullable | The language of the template; null for an own SMS text. |
reference | string, nullable | Your own id of the message, as you sent it. |
text | string, nullable | The text as sent; null once it is past its retention period (90 days, text_masked). |
text_masked | boolean | Whether text was erased after the retention period (90 days). |
segments | integer | The parts the SMS is sent and priced in (its price is the price of a segment times them); 1 for WhatsApp. |
encoding | string, nullable | The encoding of the SMS: gsm7 (160 characters, 153 a segment of a longer text) or ucs2 (70, 67); null for WhatsApp. One of: gsm7, ucs2. |
price | object, nullable | The price fixed at acceptance, in the currency of the account; for an SMS, the price of a segment times segments. Null for a test message. |
price.amount | string | |
price.currency | string | |
error | object, nullable | Why the message did not reach the recipient: a stable code and a text in the language of Accept-Language. Null while it is on its way or when delivered. |
error.code | string | |
error.text | string | |
test | boolean | Sent with a test key: through the sandbox, free. |
campaign | object, nullable | The console campaign that sent the message; null for a message of the API. |
campaign.id | string | |
campaign.name | string | |
created_at | string | When the message was accepted. |
updated_at | string | When the message last changed. |
events | object[], nullable | The history of statuses, oldest first. Only in GET /messages/{id}; null elsewhere. |
events[].status | string | |
events[].at | string | |
events[].error | object, nullable | |
events[].error.code | string | |
events[].error.text | string |
Errors
Errors are application/problem+json with a stable code (all codes). Besides the errors of every request (an invalid key, a missing permission, too many requests), this endpoint answers:
| Status | Code | Meaning |
|---|---|---|
| 422 | validation_failed | Some fields are wrong: errors lists each field with its code and a message. |
| 422 | invalid_number | The number is not a valid phone number. Send it in E.164: +, the country code and the number, without spaces or dashes. |
| 404 | template_not_found | The account has no approved template with this name in the channel. |
| 409 | template_unavailable | WhatsApp only: the template version is approved in ACE SEND but not (or no longer) approved by WhatsApp for sending, for example paused or disabled. Check the template in the console. |
| 403 | free_text_not_allowed | An SMS with an own text, but the account may only send templates. Ask your manager to enable it. |
| 422 | message_too_long | The SMS needs more segments than allowed; segments and max_segments say by how much. |
| 422 | recipient_stopped | The number is on one of the stop lists of the account (opted out or added by your team). |
| 422 | destination_unavailable | The channel does not deliver to this country for the account: no route, no price, or (SMS) no sender name for the country. Ask your manager. |
| 409 | insufficient_funds | The available balance does not cover the price; shortage is the missing amount. Top up and retry. |
| 429 | daily_limit_reached | The daily message limit of the account (or of test keys: 1000 a day) is reached. It resets at midnight in the time zone of the account; retry_after and the Retry-After header say when. Ask your manager to raise the limit. |
| 409 | idempotency_conflict | The Idempotency-Key was used within 24 hours for another body, or by a key of the other mode (test or live). |
| 409 | idempotency_in_progress | The first request with this Idempotency-Key is still running. Retry in a moment. |
json
{
"type": "about:blank",
"status": 422,
"code": "validation_failed",
"title": "Check the form fields",
"errors": [
{
"field": "variables.promo_code",
"code": "required",
"message": "Fill in this field"
}
]
}