WhatsApp API
Send Messages
Send WhatsApp template, text, image, video, audio, document and interactive messages with POST /api/v1/messages: fields, limits, responses and delivery status.
One endpoint sends every kind of WhatsApp message:
POST https://waba-api.startmessaging.com/api/v1/messages
It needs the messages:send scope. It sends one message to one number and returns once WhatsApp has accepted it.
The 24-hour rule
WhatsApp decides what you may send based on the customer service window: the 24 hours after the customer’s last message to you.
| Type | Outside the window | Inside the window |
|---|---|---|
template (approved) | Yes | Yes |
text | No | Yes |
image, video, audio, document | No | Yes |
interactive | No | Yes |
A free-form message outside the window is refused with 403 MESSAGE_WINDOW_CLOSED. Send an approved template instead. GET /api/v1/conversations reports windowOpen for each conversation (see Conversations).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient phone number. E.164 (+919876543210) is recommended; a national number (9876543210) is read in your workspace’s country. Up to 32 characters. Only phone numbers are accepted. |
type | string | Yes | One of template, text, image, video, audio, document, interactive |
template | object | When type is template | See Template messages |
text | object | When type is text | See Text messages |
media | object | When type is image, video, audio or document | See Media messages |
interactive | object | When type is interactive | See Interactive messages |
clientReference | string | No | Your own reference for this message (see clientReference) |
Send exactly the object that matches type and no other. A body with type: "text" plus a template object is a 400 VALIDATION_ERROR, not a guess.
A number that is not in your contacts yet is added automatically (source api), and the message appears in its conversation in the shared team inbox.
Some customers use a WhatsApp username and haven’t shared their number with you. Their contact has phone: null and a waUserId instead (see Contacts). The API can’t send to a waUserId, so reply to these customers from the team inbox until they share their number.
Headers
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer sm_live_your_api_key |
Content-Type | Yes | application/json (UTF-8, at most 100 KB) |
Idempotency-Key | Recommended | A unique key per message so a retry never sends twice. See Pagination & Idempotency. |
Template messages
Approved templates are the only messages that can start a conversation or reach a customer outside the 24-hour window. Templates are created in the dashboard and reviewed by Meta; see Message Templates.
| Field | Type | Required | Description |
|---|---|---|---|
template.name | string | Yes | The template’s name exactly as Meta approved it (for example order_confirmation), not the display name your team sees in the dashboard |
template.language | string | Yes | Language code, for example en_US, en_GB or hi. The same name in another language is a different template. |
template.parameters | array | When the body has variables | Body variables in order. Each value is a string of up to 1,024 characters. |
template.headerMediaUrl | string | When the header is an image, video or document | A public https URL of the file. WhatsApp downloads it. |
template.buttonParameters | array | When a button needs a value | Send-time values for dynamic buttons (see below) |
template.carouselCards | array | For a carousel template | One entry per card, in the template’s card order (see Carousel templates) |
template.offerExpiresAt | string | For a limited-time offer that shows its end time | When the offer ends: an ISO 8601 date-time in the future (see Limited-time offers) |
Body variables: positional or named
For a template with positional variables ({{1}}, {{2}}), send the values in order:
"parameters": [{ "text": "Aarti" }, { "text": "#1042" }]
For a template with named variables ({{first_name}}, {{order_id}}), send each value with its name:
"parameters": [
{ "parameter_name": "first_name", "text": "Aarti" },
{ "parameter_name": "order_id", "text": "#1042" }
]
The template’s Send via API panel in the dashboard shows exactly which values a template needs and a request you can copy. Carousel templates are the exception: the panel’s request has no cards, and the panel still says carousels can’t be sent through the API. The API does send them; see Carousel templates.
Meta keeps the variable style (named or numbered) a template was created with. A template that Meta registered without its variables can’t be filled in, so a send answers 422 TEMPLATE_VARIABLES_NOT_REGISTERED. Duplicate the template in the dashboard and send the copy once Meta approves it.
Button parameters
Buttons that are fully static need nothing at send time. Buttons with a send-time value need an entry in buttonParameters:
| Field | Description |
|---|---|
index | Position of the button in the template, counting from 0 (0–9) |
subType | url for a link button with a variable (and for authentication codes), copy_code for a coupon button, flow for a WhatsApp Form button |
text | The value for the URL variable, the one-time code, or the flow token for a flow button |
couponCode | The coupon code a copy_code button copies, up to 15 characters (WhatsApp’s limit) |
"buttonParameters": [
{ "index": 0, "subType": "url", "text": "ORD-1042" },
{ "index": 1, "subType": "copy_code", "couponCode": "DIWALI25" }
]
If a button needs a value and you leave it out, the API answers 422 TEMPLATE_BUTTON_PARAM_REQUIRED and details lists each missing button’s index and buttonType. A template with a media header and no headerMediaUrl answers 422 TEMPLATE_HEADER_MEDIA_REQUIRED.
For one-time passwords, see the WhatsApp OTP recipe: the code goes in both parameters and buttonParameters.
Example
curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
-H "Authorization: Bearer sm_live_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-confirmation" \
-d '{
"to": "+919876543210",
"type": "template",
"template": {
"name": "order_confirmation",
"language": "en_US",
"parameters": [{ "text": "Aarti" }, { "text": "#1042" }],
"headerMediaUrl": "https://cdn.example.com/orders/1042.jpg"
},
"clientReference": "order-1042"
}'
const res = await fetch('https://waba-api.startmessaging.com/api/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SM_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-1042-confirmation',
},
body: JSON.stringify({
to: '+919876543210',
type: 'template',
template: {
name: 'order_confirmation',
language: 'en_US',
parameters: [{ text: 'Aarti' }, { text: '#1042' }],
},
clientReference: 'order-1042',
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.data.id);
import os, requests
res = requests.post(
"https://waba-api.startmessaging.com/api/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['SM_API_KEY']}",
"Idempotency-Key": "order-1042-confirmation",
},
json={
"to": "+919876543210",
"type": "template",
"template": {
"name": "order_confirmation",
"language": "en_US",
"parameters": [{"text": "Aarti"}, {"text": "#1042"}],
},
"clientReference": "order-1042",
},
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["data"]["id"])
<?php
$payload = [
'to' => '+919876543210',
'type' => 'template',
'template' => [
'name' => 'order_confirmation',
'language' => 'en_US',
'parameters' => [['text' => 'Aarti'], ['text' => '#1042']],
],
'clientReference' => 'order-1042',
];
$ch = curl_init('https://waba-api.startmessaging.com/api/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('SM_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-1042-confirmation',
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$response = json_decode(curl_exec($ch), true);
echo $response['data']['id'];
Carousel templates
A carousel template sends 2 to 10 cards that scroll sideways. A carousel always sends every card it was created with, so template.carouselCards needs one entry per card, in the same order as the template:
| Field | Required | Description |
|---|---|---|
headerMediaUrl | Yes | The card’s image or video (whichever the template’s cards were created with), as a public https URL without credentials, up to 2,048 characters. Required on every send: WhatsApp has no default. |
parameters | When the card text has variables | The card text’s variables, in order, exactly like template.parameters for the message body |
buttonParameters | When a card button is a link with a variable | Up to 2 entries, each { "index", "subType": "url", "text" }. index counts the card’s own buttons from 0. Quick-reply and phone buttons need nothing. |
template.parameters still fills the message body above the cards, if it has variables.
If a card is missing something, or you send a different number of cards, the API answers 422 TEMPLATE_CAROUSEL_CARDS_INCOMPLETE before anything is sent. details.expected is the template’s card count, details.received is how many you sent, and details.problems lists each gap as { "card", "missing" }, where card counts from 1 and missing is media, variables or button (with buttonIndex).
curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
-H "Authorization: Bearer sm_live_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: diwali-carousel-919876543210" \
-d '{
"to": "+919876543210",
"type": "template",
"template": {
"name": "diwali_collection",
"language": "en_US",
"parameters": [{ "text": "Aarti" }],
"carouselCards": [
{
"headerMediaUrl": "https://cdn.example.com/products/kurta-blue.jpg",
"parameters": [{ "text": "₹1,299" }],
"buttonParameters": [{ "index": 0, "subType": "url", "text": "kurta-blue" }]
},
{
"headerMediaUrl": "https://cdn.example.com/products/saree-red.jpg",
"parameters": [{ "text": "₹2,499" }],
"buttonParameters": [{ "index": 0, "subType": "url", "text": "saree-red" }]
}
]
}
}'
const res = await fetch('https://waba-api.startmessaging.com/api/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SM_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'diwali-carousel-919876543210',
},
body: JSON.stringify({
to: '+919876543210',
type: 'template',
template: {
name: 'diwali_collection',
language: 'en_US',
parameters: [{ text: 'Aarti' }],
carouselCards: products.map((p) => ({
headerMediaUrl: p.imageUrl,
parameters: [{ text: p.price }],
buttonParameters: [{ index: 0, subType: 'url', text: p.slug }],
})),
},
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
import os, requests
res = requests.post(
"https://waba-api.startmessaging.com/api/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['SM_API_KEY']}",
"Idempotency-Key": "diwali-carousel-919876543210",
},
json={
"to": "+919876543210",
"type": "template",
"template": {
"name": "diwali_collection",
"language": "en_US",
"parameters": [{"text": "Aarti"}],
"carouselCards": [
{
"headerMediaUrl": p["image_url"],
"parameters": [{"text": p["price"]}],
"buttonParameters": [{"index": 0, "subType": "url", "text": p["slug"]}],
}
for p in products # one per card, in the template's order
],
},
},
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
Limited-time offers
A limited-time offer template can show a countdown to the end of the offer. If it does, every send needs template.offerExpiresAt: when the offer ends, as an ISO 8601 date-time (for example 2026-10-20T18:30:00Z) that is still in the future. WhatsApp counts the offer down to it.
- No
offerExpiresAton a template that shows the end time:422 TEMPLATE_OFFER_EXPIRY_REQUIRED. - An end time that has already passed:
422 TEMPLATE_OFFER_EXPIRY_INVALID.
Offer templates usually also have a coupon button (subType: "copy_code", couponCode up to 15 characters) and a link button.
curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
-H "Authorization: Bearer sm_live_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: diwali-offer-919876543210" \
-d '{
"to": "+919876543210",
"type": "template",
"template": {
"name": "diwali_flash_sale",
"language": "en_US",
"parameters": [{ "text": "Aarti" }],
"headerMediaUrl": "https://cdn.example.com/diwali-sale.jpg",
"offerExpiresAt": "2026-10-20T18:30:00Z",
"buttonParameters": [
{ "index": 0, "subType": "copy_code", "couponCode": "DIWALI25" },
{ "index": 1, "subType": "url", "text": "diwali" }
]
}
}'
const endsAt = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours from now
const res = await fetch('https://waba-api.startmessaging.com/api/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SM_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'diwali-offer-919876543210',
},
body: JSON.stringify({
to: '+919876543210',
type: 'template',
template: {
name: 'diwali_flash_sale',
language: 'en_US',
parameters: [{ text: 'Aarti' }],
headerMediaUrl: 'https://cdn.example.com/diwali-sale.jpg',
offerExpiresAt: endsAt.toISOString(),
buttonParameters: [
{ index: 0, subType: 'copy_code', couponCode: 'DIWALI25' },
{ index: 1, subType: 'url', text: 'diwali' },
],
},
}),
});
import os, requests
from datetime import datetime, timedelta, timezone
ends_at = datetime.now(timezone.utc) + timedelta(hours=48)
res = requests.post(
"https://waba-api.startmessaging.com/api/v1/messages",
headers={
"Authorization": f"Bearer {os.environ['SM_API_KEY']}",
"Idempotency-Key": "diwali-offer-919876543210",
},
json={
"to": "+919876543210",
"type": "template",
"template": {
"name": "diwali_flash_sale",
"language": "en_US",
"parameters": [{"text": "Aarti"}],
"headerMediaUrl": "https://cdn.example.com/diwali-sale.jpg",
"offerExpiresAt": ends_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
"buttonParameters": [
{"index": 0, "subType": "copy_code", "couponCode": "DIWALI25"},
{"index": 1, "subType": "url", "text": "diwali"},
],
},
},
)
Text messages
| Field | Type | Required | Description |
|---|---|---|---|
text.body | string | Yes | The message text, 1 to 4,096 characters |
Text messages need an open 24-hour window.
curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
-H "Authorization: Bearer sm_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"type": "text",
"text": { "body": "Hi Aarti, your order #1042 has shipped and arrives on Friday." }
}'
Media messages
Set type to image, video, audio or document and describe the file in media. Media messages need an open 24-hour window.
| Field | Type | Required | Description |
|---|---|---|---|
media.url | string | Yes | A public https URL, without credentials, up to 2,048 characters |
media.caption | string | No | Text shown under an image, video or document, up to 1,024 characters. Not allowed on audio. |
media.filename | string | No | Documents only: the file name the recipient sees, up to 240 characters |
Media is sent by URL only. There is no upload endpoint: WhatsApp downloads the file from your link when it delivers the message, and StartMessaging never fetches it. Make sure the URL is publicly reachable over HTTPS and serves the right Content-Type. If WhatsApp can’t fetch the file, the send is refused (422 MESSAGE_REJECTED) or arrives later as a message.failed webhook.
WhatsApp’s own limits apply to the file:
| Type | Common formats | Max size |
|---|---|---|
image | JPEG, PNG | 5 MB |
video | MP4 (H.264 video, AAC audio), 3GPP | 16 MB |
audio | AAC, MP3, OGG (Opus), AMR, M4A | 16 MB |
document | PDF, DOC(X), XLS(X), PPT(X), TXT and other files | 100 MB |
Image
{
"to": "+919876543210",
"type": "image",
"media": {
"url": "https://cdn.example.com/products/kurta-blue.jpg",
"caption": "Back in stock in your size"
}
}
Document
curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
-H "Authorization: Bearer sm_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"type": "document",
"media": {
"url": "https://cdn.example.com/invoices/1042.pdf",
"filename": "invoice-1042.pdf",
"caption": "Your invoice for order #1042"
}
}'
Audio
{
"to": "+919876543210",
"type": "audio",
"media": { "url": "https://cdn.example.com/voice/welcome.mp3" }
}
Sending media.caption with type: "audio" is a 400 VALIDATION_ERROR, because WhatsApp audio messages have no caption.
Interactive messages
Interactive messages show reply buttons or a list menu. They need an open 24-hour window and the Interactive Messages plan feature, which Team, Business, Enterprise and the free trial include and no Developer plan does (Developer Free included); a plan without it answers 403 PLAN_FEATURE_REQUIRED.
| Field | Type | Required | Description |
|---|---|---|---|
interactive.interactiveType | string | Yes | button (up to 3 reply buttons) or list (a menu of rows) |
interactive.body | string | Yes | The message text, up to 1,024 characters |
interactive.header | object | No | type: text, image, video or document. text (up to 60 characters) for a text header, mediaUrl (public https) for a media header. |
interactive.footer | string | No | Small print under the body, up to 60 characters |
interactive.buttons | array | For button | 1 to 3 buttons, each { id, title }. id up to 256 characters, title up to 20. |
interactive.buttonLabel | string | For list | Label of the button that opens the list, up to 20 characters |
interactive.sections | array | For list | Sections, each with an optional title (up to 24 characters; required by WhatsApp when there is more than one section) and rows |
Each list row is { id, title, description }: id up to 200 characters, title up to 24, optional description up to 72. WhatsApp allows 10 rows in total across all sections.
When the customer taps a button or row, you receive a message.received webhook with type: "interactive" and reply: { kind, id, title }. Branch on id (the id you set), not on title.
Reply buttons
curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
-H "Authorization: Bearer sm_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"type": "interactive",
"interactive": {
"interactiveType": "button",
"header": { "type": "text", "text": "Order #1042" },
"body": "Would you like to reorder your last purchase?",
"footer": "Reply STOP to opt out",
"buttons": [
{ "id": "reorder", "title": "Reorder" },
{ "id": "later", "title": "Not now" }
]
}
}'
List
{
"to": "+919876543210",
"type": "interactive",
"interactive": {
"interactiveType": "list",
"body": "Which size would you like?",
"buttonLabel": "Choose a size",
"sections": [
{
"title": "Sizes",
"rows": [
{ "id": "size-s", "title": "Small", "description": "Chest 36–38 in" },
{ "id": "size-m", "title": "Medium", "description": "Chest 38–40 in" },
{ "id": "size-l", "title": "Large", "description": "Chest 40–42 in" }
]
}
]
}
}
Location and contact-card messages can’t be sent through the API.
Your own reference: clientReference
Send clientReference with a message to tie it to your own record: an order number, a login attempt id, a ticket. It is never shown to the customer. It comes back on:
- the send response,
GET /api/v1/messages/{id},- the conversation’s message list,
- every
message.sent,message.delivered,message.readandmessage.failedwebhook for that message, message.receivedwebhooks when the customer replies to that message (context.clientReference).
So you can match a receipt or a reply to your record without storing our message id. It is a string of 1 to 256 printable ASCII characters; spaces are allowed, but not only spaces.
clientReference is not an idempotency key: two sends with the same reference are two messages. Use the Idempotency-Key header to make retries safe.
Response
201 Created means WhatsApp accepted the message.
{
"data": {
"id": "3f6c2a1e-8b1d-4c53-9e7a-2b5d8f0c4a11",
"status": "sent",
"conversationId": "8a1b9c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
"contactId": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
"to": "+919876543210",
"type": "template",
"clientReference": "order-1042",
"createdAt": "2026-10-02T10:00:00.000Z"
},
"meta": {
"requestId": "b7c1e0d2-5a4f-4c3b-9e8d-1f2a3b4c5d6e",
"timestamp": "2026-10-02T10:00:00.000Z"
}
}
| Field | Description |
|---|---|
id | Message id. Look it up with GET /api/v1/messages/{id}. |
status | sent once WhatsApp accepted it |
conversationId | The inbox conversation the message is in |
contactId | The recipient contact |
to | The recipient, normalised to E.164 |
type | What was sent |
clientReference | The reference you sent, or null |
createdAt | When WhatsApp accepted it |
Common send errors
| HTTP | Code | What to do |
|---|---|---|
| 400 | VALIDATION_ERROR | Fix the body; error.message names the field |
| 400 | MOBILE_INVALID | to is not a valid mobile number |
| 400 | WABA_NOT_CONNECTED | Reconnect WhatsApp on the Connect page |
| 402 | PLAN_MESSAGE_LIMIT_REACHED | This month’s API message allowance is used up (see below) |
| 402 | SUBSCRIPTION_REQUIRED | The workspace is read-only because Razorpay gave up on a subscription renewal; fix billing in the dashboard. It can also appear briefly when a trial or cancelled subscription has just ended, until the workspace moves to Developer Free. |
| 402 | META_PAYMENT_REQUIRED | Add or fix the payment method in WhatsApp Manager |
| 403 | MESSAGE_WINDOW_CLOSED | Send an approved template instead |
| 404 | TEMPLATE_NOT_FOUND | Check template.name and template.language |
| 422 | TEMPLATE_NOT_APPROVED | Wait for Meta’s approval; details.status shows the current status |
| 422 | TEMPLATE_CAROUSEL_CARDS_INCOMPLETE | Send every card with its media and values; details.problems names each gap |
| 422 | TEMPLATE_OFFER_EXPIRY_REQUIRED, TEMPLATE_OFFER_EXPIRY_INVALID | Send template.offerExpiresAt, in the future |
| 422 | TEMPLATE_VARIABLES_NOT_REGISTERED | Duplicate the template in the dashboard and send the copy |
| 422 | CONTACT_OPTED_OUT | The customer replied STOP; they must opt back in first |
| 422 | MESSAGE_REJECTED | WhatsApp refused the request itself; details.metaCode is Meta’s error number |
| 429 | RATE_LIMIT_EXCEEDED | Wait for Retry-After seconds |
| 502 | WABA_CONNECT_FAILED | Transient WhatsApp failure; retry later with the same Idempotency-Key |
The full list is on Errors & Limits.
Monthly API message allowance
Each plan includes a number of messages a month sent through the API (POST /api/v1/messages and POST /api/v1/trigger/send). Only sends that WhatsApp accepted count: a send that is refused, by us or by WhatsApp, does not use up the allowance. The month follows India time and starts again on the 1st.
| Plan | Messages a month | Where sends stop |
|---|---|---|
| Free trial | 1,000 | At 1,000, authentication templates included |
| Developer Free | 100 | At 100, authentication templates included |
| Developer 1K, 10K, 100K | 1,000, 10,000, 100,000 | At 3× the allowance. Authentication templates are never stopped. |
| Team, Business, Enterprise | Unlimited (fair use) | Never |
When sends stop, the API answers 402 PLAN_MESSAGE_LIMIT_REACHED:
{
"statusCode": 402,
"error": {
"code": "PLAN_MESSAGE_LIMIT_REACHED",
"message": "This workspace has sent the 100 messages its plan includes through the API this month. …",
"details": {
"limit": 100,
"stopAt": 100,
"sent": 100,
"period": "2026-10",
"resetsAt": "2026-10-31T18:30:00.000Z"
}
},
"meta": { "requestId": "…", "timestamp": "2026-10-20T10:00:00.000Z", "path": "/api/v1/messages" }
}
details.sent is this month’s count, details.stopAt is where sends stop, and details.resetsAt is when the count starts again (midnight India time on the 1st). Nothing upgrades or charges by itself: choose a bigger Developer size or another plan to send more. To warn your own users before this happens, watch messages in GET /api/v1/me.
Get a message
GET /api/v1/messages/{id} looks up a message’s delivery status. Needs the messages:read scope. For real-time updates, subscribe to the message.* webhooks instead of polling.
curl https://waba-api.startmessaging.com/api/v1/messages/3f6c2a1e-8b1d-4c53-9e7a-2b5d8f0c4a11 \
-H "Authorization: Bearer sm_live_your_api_key"
{
"data": {
"id": "3f6c2a1e-8b1d-4c53-9e7a-2b5d8f0c4a11",
"conversationId": "8a1b9c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
"direction": "outbound",
"type": "template",
"status": "delivered",
"to": "+919876543210",
"from": null,
"text": "Hi Aarti, your order #1042 is confirmed.",
"templateName": "order_confirmation",
"failureCode": null,
"failureReason": null,
"pricing": { "category": "utility", "type": "regular" },
"apiKeyId": "0e9d8c7b-6a5f-4e3d-2c1b-0a9f8e7d6c5b",
"clientReference": "order-1042",
"form": null,
"timestamp": "2026-10-02T10:00:00.000Z"
},
"meta": { "requestId": "…", "timestamp": "2026-10-02T10:00:05.000Z" }
}
| Field | Description |
|---|---|
direction | outbound (from you) or inbound (from the customer) |
type | text, template, image, video, audio, document, sticker or interactive. Kinds the API does not model (a location, a contact card, a reaction) read as text. |
status | Delivery status (see below) |
to / from | The customer’s number: to on outbound messages, from on inbound |
text | The words: the text, a template body with its variables filled in, a caption, or the label of a tapped option. null when there are none. |
templateName | The template sent, for a template message |
failureCode / failureReason | WhatsApp’s error code (for example 131049) and reason when the message failed |
pricing | How Meta classified the message for billing: category and type (see below). null for inbound messages and until WhatsApp’s first receipt arrives. |
apiKeyId | The API key that sent it; null for messages sent another way |
clientReference | The reference sent with it through the API; null otherwise |
form | The answers when the customer submitted a WhatsApp Form; null otherwise |
A message of another workspace answers 404 MESSAGE_NOT_FOUND, exactly like a missing one.
Pricing
pricing records what WhatsApp’s delivery receipts say about how Meta billed an outbound message. Meta bills your WhatsApp Business Account directly; it is never a StartMessaging charge.
| Field | Values |
|---|---|
pricing.category | marketing, utility, authentication, authentication-international, service, or referral_conversion (inside a Click-to-WhatsApp ad’s free window) |
pricing.type | regular (Meta charged the message), free_customer_service (a service reply inside the monthly free allowance) or free_entry_point (inside a Click-to-WhatsApp ad’s free window) |
Meta may add new values; treat an unknown one as information, not an error.
Message statuses
| Status | Meaning |
|---|---|
queued | Recorded but not yet accepted by WhatsApp. It is part of the status list, but a message normally starts at sent. |
sent | WhatsApp accepted the message |
delivered | It reached the customer’s phone |
read | The customer opened it (only if they have read receipts on) |
failed | WhatsApp could not deliver it; see failureCode and failureReason |
Outbound messages move sent → delivered → read, or end in failed. Inbound messages read delivered. Each change also fires the matching message.* webhook.
List your WhatsApp numbers
GET /api/v1/phone-numbers returns the sender numbers of the connected WhatsApp Business Account with WhatsApp’s quality rating and messaging limit. Needs no scope. All numbers come back in one page (nextCursor is always null).
curl https://waba-api.startmessaging.com/api/v1/phone-numbers \
-H "Authorization: Bearer sm_live_your_api_key"
{
"data": {
"items": [
{
"id": "2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091",
"displayPhoneNumber": "+918010012345",
"verifiedName": "Meera Boutique",
"qualityRating": "green",
"messagingLimitPerDay": 1000,
"status": "active"
}
],
"nextCursor": null
},
"meta": { "requestId": "…", "timestamp": "2026-10-02T10:00:00.000Z" }
}
| Field | Description |
|---|---|
qualityRating | green, yellow, red, or unknown (not rated yet, which is not the same as healthy) |
messagingLimitPerDay | How many customers you may start conversations with per rolling 24 hours. Meta sets it per business portfolio, so every number in the portfolio shares it. -1 is unlimited; null is unknown. |
status | pending, active, flagged or banned. Only an active number can send. |
FAQ
Can I upload a file and reuse a media id? No. The API sends media by URL only. Host the file on a public HTTPS URL; WhatsApp downloads it each time you send.
Why did my text message fail with MESSAGE_WINDOW_CLOSED? The customer has not messaged you in the last 24 hours. Outside that window WhatsApp delivers only approved templates. Send a template, and switch to free-form messages once the customer replies.
Do I need to create the contact before sending?
No. A number that is not in your contacts is added automatically with source api. On plans with a contact cap, a send to a new number over the cap answers 403 PLAN_LIMIT_REACHED; numbers you already have can still be messaged.
Does StartMessaging charge per message?
No. Meta bills message charges directly to your WhatsApp Business Account. StartMessaging charges only the subscription. Each plan includes a monthly number of API messages (see Monthly API message allowance), and each message’s pricing shows how Meta classified it.
Can I send carousel or limited-time offer templates?
Yes. Send each carousel card’s media and values in template.carouselCards, and an offer’s end time in template.offerExpiresAt. Campaigns can’t send these templates, so the API (or the inbox) is the way to send them. For a carousel, build the request from Carousel templates rather than the dashboard’s Send via API panel, which has no cards and still says carousels can’t be sent through the API.
Can I send location or contact-card messages?
Not through the API. Supported types are template, text, image, video, audio, document and interactive.