Send a message
/messaging/v1/messagesSends one SMS or WhatsApp message to one number. Ponitor returns the message, already charged and queued. Its status changes as the network reports back.
Request body
- SMS or WHATSAPP.Example:
SMS - For SMS, your approved Sender ID or Short Code. For WhatsApp, your WhatsApp number (+231770000000, with or without the +).Example:
ACME - The phone number, in international format, with or without the +. On WhatsApp, it can also be the ID WhatsApp gives someone who hides their number (LR.1349…), from a received message's sender. Authentication templates can only go to a phone number.Example:
+231770000000 - The handle of the workspace to send from and charge, such as
gbarnga-branch. Leave it out for Main. See Workspaces.Example:gbarnga-branch - Your own reference, up to 64 characters, unique within your team. If you reuse one, you get the original message back with
200. Nothing is sent or charged again. See Retries and externalId.Example:order-42 - The message's text. For SMS, 2 to 640 characters. For WhatsApp, 2 to 4,096 characters, inside the 24-hour window. See WhatsApp replies.Example:
Your Acme order 42 is on its way. - WhatsApp only, inside the 24-hour window. A file at a link. See WhatsApp replies for the types and sizes each one takes.Example:
{ "url": "https://example.com/r.jpg", "caption": "Receipt" } - WhatsApp only, with
textof up to 1,024 characters, inside the 24-hour window. Up to 3 reply buttons. See Buttons, lists and links.Example:[{ "id": "yes", "title": "Yes" }] - WhatsApp only, with
textof up to 1,024 characters, inside the 24-hour window. A list of up to 10 rows.2 child parameters
- The words on the button that opens the list, up to 20 characters.
- 1 to 10 rows.
- WhatsApp only, with
textof up to 640 characters, inside the 24-hour window. One button that opens a link. - WhatsApp only, inside the 24-hour window. A WebP of exactly 512 by 512 pixels, under 100 KB.
1 child parameter
- A public link to the sticker.
- WhatsApp only, inside the 24-hour window. A pin the customer can open in their maps.Example:
{ "latitude": 6.3005, "longitude": -10.7969, "name": "Office" } - WhatsApp only, inside the 24-hour window. Up to 10 contact cards. The customer can call or save each one.
- WhatsApp only. An approved template by its ID, with its values. See Send a template.Example:
{ "id": "6f1c2a3e-8b4d-4e5f-9a1b-2c3d4e5f6a7b", "body": ["Blama"] }3 child parameters
- The Template ID from WhatsApp › Templates in the app. It must be approved for the number you send from.
- The values for {{1}}, {{2}} and so on, in order. Up to 20 values of up to 1,024 characters each.
- The values for the buttons' placeholders, in order.
- WhatsApp only. An SMS to send instead if the number isn't on WhatsApp. See If they're not on WhatsApp.Example:
{ "sender": "ACME", "text": "Your order 42 is ready." } - The older way to send a message. Text for SMS, or for WhatsApp an object such as
{ "type": "text", "text": { "body": "…" } }. It still works. Use text, or the field named after the message's type.Example:Your Acme order 42 is on its way. - The older way to send a WhatsApp template. The Template ID from WhatsApp › Templates, and all its values in one list. It still works. Use template.Example:
"6f1c2a3e-8b4d-4e5f-9a1b-2c3d4e5f6a7b", ["Blama", "LRD 4,500"]
Send the content in one field only: text, image, video, audio, document, sticker, location, contacts or template, or the older content or templateId. buttons, list or link go with text, one of them at a time. Fields not listed here are refused. See Sending SMS and Sending WhatsApp messages for how each channel works.
Example request
- curl
- Node.js
- Python
- PHP
curl -X POST "https://sandbox-api.ponitor.com/messaging/v1/messages" \
-H "apiKey: $PONITOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "SMS",
"sender": "ACME",
"recipient": "+231770000000",
"externalId": "order-42",
"text": "Your Acme order 42 is on its way."
}'
const response = await fetch('https://sandbox-api.ponitor.com/messaging/v1/messages', {
method: 'POST',
headers: {
apiKey: process.env.PONITOR_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel: 'SMS',
sender: 'ACME',
recipient: '+231770000000',
externalId: 'order-42',
text: 'Your Acme order 42 is on its way.',
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://sandbox-api.ponitor.com/messaging/v1/messages",
headers={"apiKey": os.environ["PONITOR_API_KEY"]},
json={
"channel": "SMS",
"sender": "ACME",
"recipient": "+231770000000",
"externalId": "order-42",
"text": "Your Acme order 42 is on its way.",
},
timeout=30,
)
print(response.status_code, response.json())
<?php
$ch = curl_init('https://sandbox-api.ponitor.com/messaging/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'apiKey: ' . getenv('PONITOR_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'channel' => 'SMS',
'sender' => 'ACME',
'recipient' => '+231770000000',
'externalId' => 'order-42',
'text' => 'Your Acme order 42 is on its way.',
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
echo $status, ' ', $body, PHP_EOL;
Responses
- 201
- 200
- 400
- 401
- 403
- 422
- 429
- 500
Created. The message was accepted, charged and queued. The body is the message.
{
"message": {
"id": "e2c38865-dc0a-4299-baef-c07a9bfa9c33",
"content": "Your Acme order 42 is on its way.",
"text": "Your Acme order 42 is on its way.",
"sender": "ACME",
"status": "QUEUED",
"channel": "SMS",
"direction": "OUTBOUND",
"recipient": "+231770000000",
"messageType": null,
"inServiceWindow": null,
"cost": 0.05,
"conversationId": null,
"failedReason": null,
"externalId": "order-42",
"createdAt": "2026-10-05T09:30:00.112Z",
"updatedAt": "2026-10-05T09:30:00.112Z"
}
}
OK. The externalId was already used. The body is the original message in its current state, in the same shape as 201. Nothing new was sent or charged, even if the details differ.
Bad Request. A field is missing or invalid (E01001, with each field in details), the sender isn't yours (E10001), the template or the 24-hour window doesn't allow it (E10005, E10006), or the balance is too low (E03001).
{
"error": {
"type": "validation_error",
"code": "E01001",
"name": "ValidationError",
"message": "The request has 1 problem.",
"doc_url": "https://docs.ponitor.com/api/errors#E01001",
"request_id": "c1f0b7a2-5e1d-4c8e-9a0b-7d6e5f4c3b2a",
"param": "externalId",
"details": [
{ "param": "externalId", "message": "externalId is required" }
]
}
}
{
"error": {
"type": "billing_error",
"code": "E03001",
"name": "InsufficientBalance",
"message": "Insufficient account balance. Top up to continue sending.",
"doc_url": "https://docs.ponitor.com/api/errors#E03001",
"request_id": "2d017493-d307-4628-b9a3-068160069183"
}
}
Every error has this error object. The body also still carries fields from before codes, which are deprecated. See The error body.
Unauthorized. The API key is missing (E02001), wrong or revoked (E02002), or sent to the other base URL (E02003). See API keys and base URLs.
{
"error": {
"type": "auth_error",
"code": "E02002",
"name": "InvalidApiKey",
"message": "The API key is wrong, or was revoked.",
"doc_url": "https://docs.ponitor.com/api/errors#E02002",
"request_id": "8b1e4f0c-2d3a-4b5c-9e6f-7a8b9c0d1e2f"
}
}
Forbidden. Your team hasn't turned on the Messaging API, hasn't completed compliance (E02010) or is suspended (E02011), or the workspace can't send (E02012, E02014, E02015).
{
"error": {
"type": "permission_error",
"code": "E02010",
"name": "TeamNotVerified",
"message": "Your team hasn't completed compliance.",
"doc_url": "https://docs.ponitor.com/api/errors#E02010",
"request_id": "5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c"
}
}
Unprocessable Entity. The person unsubscribed from your WhatsApp marketing (E10008). Marketing templates aren't sent to them. Nothing is charged. Authentication and utility templates still go. See When someone unsubscribes.
{
"error": {
"type": "unprocessable_error",
"code": "E10008",
"name": "RecipientOptedOut",
"message": "This number unsubscribed, so marketing templates aren't sent to it. Utility and authentication templates still go.",
"doc_url": "https://docs.ponitor.com/api/errors#E10008",
"request_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
}
}
Too Many Requests. Your team went over its rate limit (E01011). Nothing was sent or charged. Wait, then send the same request again with the same externalId.
Server Error. Something failed on our side (E01000). Retry with the same externalId. If the first attempt went through, you get that message back.
Send a template
Set template to an approved template's ID and its values. The ID is the Template ID on the template's page in WhatsApp › Templates.
{
"channel": "WHATSAPP",
"sender": "+231770000000",
"recipient": "+231770123456",
"externalId": "remind-0412",
"template": {
"id": "6f1c2a3e-8b4d-4e5f-9a1b-2c3d4e5f6a7b",
"body": ["Blama", "LRD 4,500", "18 June"],
"buttons": ["loans/0412"]
}
}
| Field | Type | Meaning |
|---|---|---|
id | string | The template's ID, from the app. It must be approved for the number you send from. |
body | string[] | The values for {{1}}, {{2}} and so on, in order. Up to 20 values of up to 1024 characters each. Leave it out if the template has no placeholders. |
buttons | string[] | The values for the buttons' placeholders, in order, such as the end of a link button's address. Leave it out if the buttons have none. |
A template belongs to a WhatsApp account. Send it from a number in that account. Each WhatsApp account has its own copy of a template, with its own ID.
The header (text, a photo, a video or a document) is sent as approved. A template with too few or too many values is refused with 400. The message says how many values it takes. The older templateId with templateParameters still works.
If they're not on WhatsApp
Add smsFallback to a WhatsApp message to send an SMS if the number isn't on WhatsApp. The WhatsApp message goes first. If WhatsApp reports that the number isn't on WhatsApp, Ponitor sends the SMS once from your Sender ID. It's charged as an SMS.
{
"channel": "WHATSAPP",
"sender": "+231770000000",
"recipient": "+231880000000",
"externalId": "ready-0412",
"template": { "id": "6f1c2a3e-8b4d-4e5f-9a1b-2c3d4e5f6a7b", "body": ["Blama"] },
"smsFallback": { "sender": "ACME", "text": "Hi {{firstname|there}}, your order 42 is ready." }
}
| Field | What it is |
|---|---|
sender | Your approved SMS Sender ID or Short Code. |
text | Up to 640 characters. {{firstname}}, {{lastname}} and {{fullname}} are filled in from your saved contact. |
To cover contacts with no saved name, add a default after a bar. {{firstname|there}} becomes "there".
The SMS is a separate message with its own ID, status and webhook events. The WhatsApp message's smsFallback shows the SMS's messageId once it's sent. Get the message to read it. The SMS can't go to a WhatsApp user ID, because there's no number to text.
WhatsApp replies
Inside the 24-hour window, you can reply with text, one file, a sticker, a location or contact cards. Put it in the field named after its type.
| Field | Holds | What it takes |
|---|---|---|
text | The text | 2 to 4,096 characters. WhatsApp formatting (*bold*, _italic_, ~strikethrough~) works. |
image | url, caption (optional) | JPEG or PNG, up to 5 MB. |
video | url, caption (optional) | MP4 with H.264 video and AAC audio, up to 16 MB. The caption is sent as a separate text message after the video is delivered, and charged as a reply. |
audio | url | OGG with the Opus codec arrives as a voice note. MP3, AAC and AMR arrive as an audio file. Up to 16 MB. No caption. |
document | url, filename (optional), caption (optional) | PDF, Word (DOC, DOCX), Excel (XLSX) or PowerPoint (PPTX), up to 20 MB. The caption is sent as a separate text message after the document is delivered, and charged as a reply. |
sticker | url | A WebP of exactly 512 by 512 pixels, under 100 KB, with a clear background. Not animated. |
location | latitude, longitude, name and address (optional) | A pin the customer can open in their maps. The pin shows name, or address if there's no name. |
contacts | A list of cards: name, phones and emails (optional) | Up to 10 cards, each with up to 5 phones and 5 emails. The customer can call or save each one. |
Each field goes next to channel, sender, recipient and externalId, as in the example request. The older content ({ "type": "image", "image": { "link": "…" } }) still works.
For example, an image with a caption:
"image": {
"url": "https://example.com/receipts/order-42.jpg",
"caption": "Your receipt for order 42"
}
Buttons, lists and links
Send text with one of these fields. The customer answers with a tap.
| Field | Holds | What it takes |
|---|---|---|
buttons | A list of { "id", "title" } | Up to 3 reply buttons. Each title up to 20 characters, each id up to 128 and different. text up to 1,024 characters. |
list | button, and items: a list of { "id", "title", "description" } | button is the label that opens the list, up to 20 characters. Up to 10 rows. Each row needs a title of up to 24 characters and a description of up to 72. text up to 1,024 characters. |
link | title and url | One button that opens an https link. title up to 20 characters. text up to 640 characters. |
{
"channel": "WHATSAPP",
"sender": "+231770000000",
"recipient": "+231770123456",
"externalId": "slot-0412",
"text": "Pick a delivery slot",
"list": {
"button": "Slots",
"items": [
{ "id": "am", "title": "Morning", "description": "8am to 12pm" },
{ "id": "pm", "title": "Afternoon", "description": "12pm to 5pm" }
]
}
}
When the customer taps a button or picks a row, the message.received webhook includes reply. It has the id you sent, the title, and a row's description. Match on id. It stays the same when you change the words. Outside the 24-hour window, send an approved template with quick-reply buttons.
The file's link
- The link must open without signing in. The file is fetched when the message goes out. Keep the link working until the message is
DELIVERED. - The link must return the file's real
Content-Type, such asimage/jpegorapplication/pdf. A file whose type doesn't match isn't sent. - Send one file per message.
filenameis for your records. The customer's WhatsApp doesn't show it.
A file costs the same as a text reply. If the file can't be fetched, is too large or has an unsupported type, the message ends as FAILED or UNDELIVERED. The reason is in failedReason. See Delivery statuses.
The message object
Get a message returns the same object.
| Field | Type | Meaning |
|---|---|---|
id | uuid | Ponitor's ID for the message. |
channel | string | SMS or WHATSAPP. |
direction | string | OUTBOUND for messages you send, INBOUND for replies. |
sender | string | Your Sender ID, Short Code or WhatsApp number. |
recipient | string | The phone number, in international format. |
text, image, video, audio, document, sticker, location, contacts, order, template | The message, in the field named after its type, as you send it. order is a customer's order from your WhatsApp catalog: its catalogId and items (productId, quantity, price, currency). A file has its url (for a received file, a link that works for an hour), and its caption, mimeType, filename and size when known. A template has its id, name and language, and text holds what the customer read. | |
buttons, list, link | Sent with text, as you sent them. | |
reply | object | Set when the customer tapped a button or quick reply, or picked a list row. Has the id you sent, the title they saw, and a row's description. |
referral | object | Set when the customer wrote from your click-to-WhatsApp ad. Has the ad's headline, body, sourceUrl, sourceType, sourceId and clickId. |
forwarded | string | Set on a forwarded message. FORWARDED, or FREQUENTLY_FORWARDED if it has been forwarded many times. |
content | string or object | The same message in the older shape. Text for SMS, WhatsApp's own format for WhatsApp. |
status | string | QUEUED, SENT, DELIVERED, EXPIRED, UNDELIVERED, UNKNOWN or FAILED. See Delivery statuses. |
failedReason | string or null | Why the message wasn't delivered. Set for EXPIRED, UNDELIVERED and FAILED. |
failureCode | string or null | The same reason as a code that never changes, such as E11001. Branch on it. See Why a message wasn't delivered. |
cost | number | What the message cost, in US dollars. Refunded if it ends as FAILED. |
externalId | string | Your reference. |
conversationId | uuid or null | Groups a two-way conversation with one person, such as Short Code replies or a WhatsApp chat. |
messageType | string or null | TEMPLATE or FREE_FORM for WhatsApp. null for SMS. |
inServiceWindow | boolean or null | For WhatsApp, whether the message went out inside the 24-hour window. null for SMS. |
smsFallback | object | WhatsApp only, if you sent one. Has sender, text and messageId. messageId is the SMS's ID once it's sent, and null until then. See If they're not on WhatsApp. |
createdAt | date-time | When Ponitor accepted it. |
updatedAt | date-time | When it last changed, usually its latest status. |