# Send a batch

Source: https://joinsimplesms.com/docs/api/batches/create-batch
Index: https://joinsimplesms.com/llms.txt

`POST /v1/batches`

One body to up to 10,000 recipients, each with its own merge variables (or to contacts by tag), sent as individual messages through the normal pipeline. Numbers are normalized, deduplicated and checked against opt-outs before queueing; rejected rows are returned with reasons. Set dry_run to validate without sending.

Send your API key as a bearer token: `Authorization: Bearer ssms_sk_...`. Test keys run this endpoint against the [sandbox](/docs/sandbox); see [Authentication](/docs/authentication) for key modes and scopes.

## Request body

JSON (`Content-Type: application/json`).

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string | Yes | Example: `+15005550100`. |
| `body` | string | Yes | At most 1600 characters. Example: `Hi {{first_name}}, your order {{order_id}} has shipped.`. |
| `name` | string | No | At most 80 characters. |
| `recipients` | array of object | No | At most 10000 items. |
| `recipients[].to` | string | Yes | Example: `+14155550132`. |
| `recipients[].variables` | object | No |  |
| `tags` | array of string | No | Send to contacts carrying any of these tags (instead of recipients). |
| `contact_ids` | array of string | No |  |
| `segment_id` | string | No | Send to the contacts in this saved segment, evaluated when the batch is created (instead of recipients). |
| `topic_id` | string | No | Subscription topic. Recipients unsubscribed from it are removed at validation (`topic_unsubscribed`) and skipped at send time, like opt-outs. Omit to apply opt-outs only. Example: `tp_marketing`. |
| `scheduled_at` | string | No | Future ISO timestamp or epoch ms, up to 30 days out. |
| `dry_run` | boolean | No | Validate only: returns a BatchValidation and sends nothing. |
| `check_line_types` | boolean | No | Reject landlines (first 500 unique numbers are checked). |

## Responses

| Status | Meaning | Body |
| --- | --- | --- |
| 200 | Dry run result | `BatchValidation` |
| 201 | Batch created | `Batch` |
| 400 | Invalid request or no valid recipients | `Error` |
| 401 | Missing, malformed, or revoked API key | `Error` |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged | `Error` |
| 403 | `from` not owned, or key mode does not match the number | `Error` |
| 429 | Rate limit or quota exceeded | `Error` |

### 200: `BatchValidation` fields

| Field | Type | Description |
| --- | --- | --- |
| `object` | `batch_validation` |  |
| `counts` | object |  |
| `counts.rows` | integer |  |
| `counts.valid` | integer |  |
| `counts.invalid` | integer |  |
| `counts.duplicates` | integer |  |
| `counts.opted_out` | integer |  |
| `counts.topic_unsubscribed` | integer | Unsubscribed from the batch topic_id; 0 without a topic |
| `counts.missing_variable` | integer |  |
| `counts.landline` | integer |  |
| `counts.empty_after_merge` | integer |  |
| `unknown_variables` | array of string |  |
| `segments` | object |  |
| `segments.total` | integer |  |
| `segments.max` | integer |  |
| `segments.encoding` | `GSM-7`, `UCS-2` |  |
| `estimated_cost_usd` | number |  |
| `preview` | array of object |  |
| `preview[].to` | string |  |
| `preview[].body` | string |  |
| `preview[].segments` | integer |  |
| `rejected` | array of object |  |
| `rejected[].row` | integer | 0-based index into recipients |
| `rejected[].to` | string |  |
| `rejected[].reason` | `invalid`, `duplicate`, `opted_out`, `missing_variable`, `landline`, `empty_after_merge` |  |
| `rejected[].detail` | string |  |

### 201: `Batch` fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Example: `bc_a1B2c3D4e5F6`. |
| `object` | `batch` |  |
| `name` | string |  |
| `status` | `scheduled`, `sending`, `paused`, `complete`, `canceled` |  |
| `from` | string |  |
| `body` | string |  |
| `source` | `api`, `csv`, `contacts` |  |
| `test` | boolean |  |
| `scheduled_at` | string (date-time) | Nullable. |
| `created_at` | string (date-time) |  |
| `completed_at` | string (date-time) | Nullable. |
| `counts` | object | total = queued + sent + failed + opted_out + canceled, always. "sent" = accepted by the carrier; what happened next (delivered, failed, replies) is GET /v1/batches/{id}/results. "opted_out" also counts recipients unsubscribed from the batch topic. |
| `counts.total` | integer |  |
| `counts.queued` | integer |  |
| `counts.sent` | integer |  |
| `counts.failed` | integer |  |
| `counts.opted_out` | integer |  |
| `counts.canceled` | integer |  |
| `rejected` | object | Rows dropped at validation (not part of total), by reason. Nullable. |
| `variables` | array of string |  |
| `retries` | integer |  |

## Errors

| Status | When |
| --- | --- |
| 400 | Invalid request or no valid recipients |
| 401 | Missing, malformed, or revoked API key |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged |
| 403 | `from` not owned, or key mode does not match the number |
| 429 | Rate limit or quota exceeded |

Every error has the same JSON shape, and `request_id` matches the `X-Request-Id` response header. [Errors](/docs/errors) lists every code and what to do about it.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "What went wrong, in plain words.",
    "param": "the_field",
    "request_id": "req_a1B2c3D4e5F6g7H8"
  }
}
```

## Examples

### curl

```bash
curl -X POST "https://api.joinsimplesms.com/v1/batches" \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "from": "+15005550100",
  "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
  "topic_id": "tp_marketing"
}'
```

### Node.js

The Node.js SDK does not wrap this endpoint yet; call it with `fetch`.

```javascript
const res = await fetch('https://api.joinsimplesms.com/v1/batches', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SIMPLESMS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "from": "+15005550100",
    "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
    "topic_id": "tp_marketing"
  }),
});

if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```

### Python

The Python SDK does not wrap this endpoint yet; call it over HTTP.

```python
import json, os, urllib.request

req = urllib.request.Request(
    "https://api.joinsimplesms.com/v1/batches",
    method="POST",
    headers={
        "Authorization": f"Bearer {os.environ['SIMPLESMS_API_KEY']}",
        "Content-Type": "application/json",
    },
    data=json.dumps({
        "from": "+15005550100",
        "body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
        "topic_id": "tp_marketing"
    }).encode(),
)
with urllib.request.urlopen(req) as res:
    data = json.load(res)
```

## Related

- Guide: [Batches & broadcasts](/docs/broadcasts)
- [All batches endpoints](/docs/api#batches)
- [API reference](/docs/api)
