> ## Documentation Index
> Fetch the complete documentation index at: https://documents.xobito.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a bulk campaign

> Send one approved template to many recipients in a single request — JSON inline or CSV upload — with per-recipient variable substitution.

Send an approved WhatsApp template to many recipients at once. Each message is queued and
sent through the same engine as the dashboard's bulk campaigns, so you get the same pacing,
retries, delivery tracking, and chat-history sync — driven entirely from the API.

Two input modes:

| 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         |

Both return immediately with a `campaign_id` (messages are **queued**, not sent synchronously).
Track delivery via [outbound webhooks](/developers/webhook-events#message-delivery-status-events)
or the [Get message status](/api-reference/endpoints/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's `firstname` value.
* Static text is allowed too: `"variables": { "body": ["Welcome", "@{order}"] }`.

The same applies to `variables.header`, `variables.footer`, and `variables.button`.

<Note>
  **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).
</Note>

<Note>
  **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.
</Note>

<Warning>
  **Not supported in bulk:** carousel (multi-card) templates and authentication/OTP templates.
  Use the [single-message endpoints](/api-reference/endpoints/send-template-message) for those.
</Warning>

***

## Mode 1 — JSON inline

**Endpoint**

```
POST /api/v1/{subdomain}/campaigns/send
```

**Headers**

| Header          | Value                 |
| --------------- | --------------------- |
| `Authorization` | `Bearer <your-token>` |
| `Content-Type`  | `application/json`    |

**Body**

```json theme={null}
{
  "campaign_name": "June Promo",
  "template_name": "welcome_message_1",
  "template_language": "en",
  "variables": {
    "body": ["@{firstname}", "@{order}"]
  },
  "recipients": [
    { "phone": "+919909919284", "firstname": "Rahul", "order": "Order #4471" },
    { "phone": "+918160748516", "firstname": "Priya", "order": "Order #4472" }
  ]
}
```

**Body fields**
<ParamField body="campaign_name" type="string" required>A label for this campaign (shown in the dashboard).</ParamField>
<ParamField body="template_name" type="string" required>An **APPROVED** template name for this workspace.</ParamField>
<ParamField body="template_language" type="string" required>Template language code, e.g. `en`, `en_US`.</ParamField>
<ParamField body="variables" type="object">Per-section arrays of `@{key}` expressions. Keys: `header`, `body`, `footer`, `button`. Positional to the template's `{{1}}`, `{{2}}`, …</ParamField>
<ParamField body="header_media_id" type="string">Pre-uploaded WhatsApp media id — required only for templates with a media (IMAGE/VIDEO/DOCUMENT) header.</ParamField>
<ParamField body="recipients" type="array" required>1–1000 objects. Each MUST have `phone`. Any other keys (firstname, order, …) are stored per recipient and are addressable from `variables` via `@{key}`.</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://dash.example.com/api/v1/acme/campaigns/send" \
    -H "Authorization: Bearer <your_token>" \
    -H "Content-Type: application/json" \
    -d '{
      "campaign_name": "June Promo",
      "template_name": "welcome_message_1",
      "template_language": "en",
      "variables": { "body": ["@{firstname}"] },
      "recipients": [
        { "phone": "+919909919284", "firstname": "Rahul" },
        { "phone": "+918160748516", "firstname": "Priya" }
      ]
    }'
  ```

  ```python Python theme={null}
  import requests

  resp = requests.post(
      "https://dash.example.com/api/v1/acme/campaigns/send",
      headers={"Authorization": "Bearer <your_token>"},
      json={
          "campaign_name": "June Promo",
          "template_name": "welcome_message_1",
          "template_language": "en",
          "variables": {"body": ["@{firstname}"]},
          "recipients": [
              {"phone": "+919909919284", "firstname": "Rahul"},
              {"phone": "+918160748516", "firstname": "Priya"},
          ],
      },
  )
  print(resp.json())
  ```

  ```javascript JavaScript theme={null}
  const res = await fetch("https://dash.example.com/api/v1/acme/campaigns/send", {
    method: "POST",
    headers: {
      Authorization: "Bearer <your_token>",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      campaign_name: "June Promo",
      template_name: "welcome_message_1",
      template_language: "en",
      variables: { body: ["@{firstname}"] },
      recipients: [
        { phone: "+919909919284", firstname: "Rahul" },
        { phone: "+918160748516", firstname: "Priya" },
      ],
    }),
  });
  const body = await res.json();
  ```
</CodeGroup>

***

## Mode 2 — CSV upload

**Endpoint**

```
POST /api/v1/{subdomain}/campaigns/send-csv
```

**Form fields** (`multipart/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)                |

**CSV format** — first row is the header; one column **must** be `phone`. All other columns become
per-recipient values addressable via `@{column}`. Rows without a phone are skipped automatically.

```csv theme={null}
phone,firstname,order
+919909919284,Rahul,Order #4471
+918160748516,Priya,Order #4472
919723275223,Mukesh,Order #4473
```

→ `variables.body[0] = "@{firstname}"` fills `{{1}}`, `"@{order}"` fills `{{2}}`, resolved per row.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://dash.example.com/api/v1/acme/campaigns/send-csv" \
    -H "Authorization: Bearer <your_token>" \
    -F "campaign_name=June Promo" \
    -F "template_name=welcome_message_1" \
    -F "template_language=en" \
    -F 'variables={"body":["@{firstname}","@{order}"]}' \
    -F "file=@recipients.csv"
  ```

  ```python Python theme={null}
  import requests

  with open("recipients.csv", "rb") as f:
      resp = requests.post(
          "https://dash.example.com/api/v1/acme/campaigns/send-csv",
          headers={"Authorization": "Bearer <your_token>"},
          data={
              "campaign_name": "June Promo",
              "template_name": "welcome_message_1",
              "template_language": "en",
              "variables": '{"body":["@{firstname}","@{order}"]}',
          },
          files={"file": f},
      )
  print(resp.json())
  ```
</CodeGroup>

***

## Response

Both modes return the same envelope (HTTP `200`):

```json theme={null}
{
  "status": "success",
  "message": "campaign_queued",
  "data": {
    "campaign_id": 88,
    "campaign_name": "June Promo",
    "template_name": "welcome_message_1",
    "total_recipients": 2,
    "queued": 2
  }
}
```

| 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.                             |

<Note>
  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`](/developers/webhook-events#message-delivery-status-events)
  webhook as the message goes out.
</Note>

***

## 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](/developers/webhook-events#message-delivery-status-events).
For one-off lookups, use [Get message status](/api-reference/endpoints/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}`                                                  |

<Tip>
  Sending speed beyond your token's rate limit is handled internally by the queue — you don't
  need to throttle the API call itself. Actual delivery pace to recipients is also subject to
  WhatsApp's own per-number messaging tier.
</Tip>
