| Mode | Endpoint | Best for | Limit |
|---|---|---|---|
| JSON inline | POST /api/v1/{subdomain}/campaigns/send | programmatic sends, small/medium lists | 1,000 recipients/request |
| CSV upload | POST /api/v1/{subdomain}/campaigns/send-csv | large lists, spreadsheet exports | 50,000 rows/file |
campaign_id (messages are queued, not sent synchronously).
Track delivery via outbound webhooks
or the Get message status endpoint.
Required ability: messages.send.
How variable substitution works
A template’s body/header can contain positional placeholders{{1}}, {{2}}, … You map each
placeholder to a recipient field using a @{key} expression in variables, and supply the
value per recipient.
variables.body[0]→ fills{{1}},variables.body[1]→{{2}}, and so on.@{firstname}resolves to each recipient’sfirstnamevalue.- Static text is allowed too:
"variables": { "body": ["Welcome", "@{order}"] }.
variables.header, variables.footer, and variables.button.
Buttons: only send
variables.button for templates whose button URL is dynamic
(contains a {{n}} placeholder). Static URL buttons, quick-reply, and copy-code buttons take
no parameter — if you send one anyway, it is automatically ignored (so you can’t trigger a
parameter-count error).Media-header templates (IMAGE / VIDEO / DOCUMENT header) require a pre-uploaded
header_media_id. If the template needs one and you omit it, the request returns 422 with a
clear message.Mode 1 — JSON inline
Endpoint| Header | Value |
|---|---|
Authorization | Bearer <your-token> |
Content-Type | application/json |
A label for this campaign (shown in the dashboard).
An APPROVED template name for this workspace.
Template language code, e.g.
en, en_US.Per-section arrays of
@{key} expressions. Keys: header, body, footer, button. Positional to the template’s {{1}}, {{2}}, …Pre-uploaded WhatsApp media id — required only for templates with a media (IMAGE/VIDEO/DOCUMENT) header.
1–1000 objects. Each MUST have
phone. Any other keys (firstname, order, …) are stored per recipient and are addressable from variables via @{key}.Mode 2 — CSV upload
Endpointmultipart/form-data)
| Field | Required | Notes |
|---|---|---|
campaign_name | yes | campaign label |
template_name | yes | APPROVED template |
template_language | yes | e.g. en |
variables | no | JSON string, e.g. {"body":["@{firstname}"]} |
header_media_id | no | for media-header templates |
file | yes | the .csv (≤ 20 MB / 50,000 rows) |
phone. All other columns become
per-recipient values addressable via @{column}. Rows without a phone are skipped automatically.
variables.body[0] = "@{firstname}" fills {{1}}, "@{order}" fills {{2}}, resolved per row.
Response
Both modes return the same envelope (HTTP200):
| Field | Type | Notes |
|---|---|---|
campaign_id | integer | Use to look up the campaign later. |
total_recipients | integer | Recipients accepted (rows without a phone are excluded). |
queued | integer | Messages queued for sending. |
Per-message
message_id (WhatsApp wamid) is not in this response — messages are queued,
not yet sent. You receive each wamid in the message.sent
webhook as the message goes out.Tracking delivery
Every message — whether sent via this endpoint, the single-send endpoints, or the dashboard — emits delivery webhooks you can subscribe to:message.sent → message.delivered → message.read (or message.failed).
Each event carries the message_id so you can match it back to your records. See
Webhook Events → Message delivery status.
For one-off lookups, use Get message status.
Error responses
| Status | When | Example body |
|---|---|---|
401 | Missing / invalid token | {"status":"error","message":"Invalid API token"} |
403 | Missing ability | {"status":"error","message":"Token does not have the required ability: messages.send"} |
404 | Template not found / not approved | {"status":"error","message":"Approved template not found for the given name and language"} |
422 | Validation, or media-header template without header_media_id, or > limit recipients | {"status":"error","message":"Template \"...\" has a IMAGE header — pass \"header_media_id\" ..."} |
429 | Rate limit | {"message":"Too many requests","retry_after":45} |