# Batches & broadcasts

Source: https://jointextkit.com/docs/broadcasts
Index: https://jointextkit.com/llms.txt

A batch sends one message body to many recipients as individual texts.
Recipients never see each other, and variables personalize each body.
Recipients come from a CSV upload (console), `recipients[]` (API), or
contacts carrying a tag.

## Send a batch

```bash
curl -X POST https://api.jointextkit.com/v1/batches \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "body": "Hi {{first_name}}, order {{order_id}} ships today. Reply STOP to opt out.",
    "recipients": [
      {"to": "+14155550132", "variables": {"first_name": "Jane", "order_id": "A-1042"}},
      {"to": "(415) 555-0133", "variables": {"first_name": "Sam", "order_id": "A-1043"}}
    ]
  }'
```

The response is a `batch` object (`bc_` id) with live `counts`, plus a
`validation` summary of any rows that were dropped. To send to contacts
instead, pass `"tags": ["vip", "beta"]` (any of the tags) in place of
`recipients`. Add `scheduled_at` (up to 30 days out) to send later.

## Validate first: dry runs

Add `"dry_run": true` and nothing is sent. You get counts, every rejected
row with its reason, a rendered preview, the segment count and the
estimated cost:

| Reason | Meaning |
|---|---|
| `invalid` | Not a valid US/Canada number (e.g. a +44 number) |
| `duplicate` | Same number as an earlier row - the first one is kept |
| `opted_out` | Replied STOP to you |
| `topic_unsubscribed` | Unsubscribed from the batch's `topic_id` |
| `missing_variable` | A variable the body uses is blank on this row |
| `landline` | Only with `check_line_types: true` (first 500 numbers) |
| `empty_after_merge` | The rendered body is empty |

A row is counted under its first failing reason, so `valid` plus the
rejected counts always equals the number of rows you sent. A variable that
no row has at all (a typo like `{{frist_name}}`) fails the whole request
with `unknown_variables` instead of sending blanks.

The dry run also asks the consent check what it will decide at send time,
for the first 1,000 accepted recipients: `consent.no_opt_in` is how many
would be refused `consent_required` (a marketing message without an opt-in
on file), with up to 20 of them in `consent.no_opt_in_sample`. Record their
opt-in, or leave them out; a real send skips them and texts the rest.

The estimate is segments × (the per-segment rate + the highest carrier fee),
an upper bound: each recipient's carrier fee is known only at send (sandbox:
$0). Segments are reported too: one emoji or curly quote switches the whole message to UCS-2,
70 characters per segment instead of 160.

## Variables

Any recipient variable (CSV column) is available as `{{column_name}}`;
headers are snake_cased ("First Name" → `{{first_name}}`). Contact batches
also get `{{name}}`, `{{first_name}}`, `{{phone}}` and
`{{field:company}}`, rendered at send time so a contact edit made after
scheduling still lands. Unresolvable tags render empty, never as the raw tag.

## Pause, resume, cancel, retry

| Endpoint | What it does |
|---|---|
| `POST /v1/batches/{id}/pause` | Holds everything not yet handed to the carrier |
| `POST /v1/batches/{id}/resume` | Re-queues held messages |
| `POST /v1/batches/{id}/cancel` | Cancels a scheduled batch, or stops one mid-send |
| `POST /v1/batches/{id}/retry` | Re-sends failures that can succeed on a retry |

A message already in flight when you pause or cancel still sends. Retry only
re-queues transient failures (rate limits, quota, carrier errors), at most 3
attempts per recipient, and only once the batch is `complete`; opt-outs,
invalid numbers and anyone this batch already messaged are never retried.
Two retries at once can't double-send.

## Progress

`GET /v1/batches/{id}` returns `counts`: `total = queued + sent + failed +
opted_out + canceled`, always. "Sent" means accepted by the carrier.
`GET /v1/batches/{id}/recipients?status=failed`
lists rows with `error` and `retryable`. In the console, the batch page
shows live progress and a **Download failed rows** CSV (your original
columns plus the reason), ready to fix and re-upload.

## Audience and topic

Besides `recipients`, a batch can go to contacts: `segment_id` (a saved
[segment](/docs/contacts#segments), evaluated when the batch is created),
`tags`, or `contact_ids`.

`topic_id` names a [subscription topic](/docs/contacts#subscription-topics).
Recipients unsubscribed from it are removed at validation (counted as
`topic_unsubscribed`) and checked again when each message is sent, exactly
like opt-outs; at send time they are counted with `opted_out` and their row's
`error` is `topic_unsubscribed`. Omit `topic_id` and only opt-outs apply.
Console broadcasts default to Marketing.

## Compliance check

The console runs a check before a broadcast is sent (it is part of the
dry run). It checks:

| Check | Result |
| --- | --- |
| Sender | Pass in sandbox or with an approved registration; a warning otherwise |
| Opt-out wording | Warning when the message has no "Reply STOP" instruction |
| Content | **Blocks** on a live sender when the content screen refuses the message; a warning in sandbox |
| Public link shortener | Warning |
| Quiet hours | Says what will happen: "312 recipients are in quiet hours and will be delivered at 8:00am their time". Each recipient outside 8:00am to 9:00pm local time (8:00pm in Florida, Maryland and Oklahoma; read from the area code) is held until their 8:00am, whatever the message is. Pass `"quiet_hours": "block"` to refuse those recipients instead. See [quiet hours](/docs/compliance-checks#quiet-hours) |
| Opt-outs on file | Warning on your first broadcast when you have imported contacts and have no opt-outs on file: [import the list](/docs/opt-out#bring-your-opt-outs) from wherever these people were texted before |
| Consent on file | Warning when contacts in the audience have no opt-in recorded at import, and always for an uploaded list. A broadcast is marketing: a recipient with no opt-in on file is skipped with `consent_required` ([opt-in on file](/docs/compliance-checks#opt-in-on-file)) |
| Topic | Warning when no topic is set |
| Merge variables, empty audience | **Blocks** (the API refuses these too) |

Warnings never stop a send: consent, timing and wording are your
responsibility under the [Messaging Policy](/messaging-policy).
**Send test** texts the first rendered message to your own verified phone.

## Results

`GET /v1/batches/{id}/results` (and the console's broadcast page) reports
what happened after the queue did its part:

```json
{
  "attempted": 100000, "delivered": 97921, "failed": 1228, "pending": 851,
  "replies": 1402, "stops": 312, "spent_usd": 1142.28,
  "failures": [
    { "code": "invalid_number", "title": "Number not in service", "count": 640,
      "explanation": "The destination number does not exist or is no longer assigned to a phone.",
      "action": "Recommended: stop sending to this number..." }
  ]
}
```

- `attempted = delivered + failed + pending`. Skipped (opted out or
  unsubscribed), canceled and still-queued recipients are reported separately.
- `delivered` and `failed` come from carrier delivery receipts. `pending`
  was accepted by the carrier with no final receipt; `no_receipt` of those
  never got one. Sandbox outcomes are simulated.
- `replies` are messages recipients sent to the sending number within 72
  hours of the batch finishing (STOP keywords are not counted as replies).
  `stops` are recipients who opted out in that time and are still opted out.
- `spent_usd` is the sum of each message's recorded `price`.
- `failures` groups failed recipients by [failure code](/docs/errors), plus
  the reasons a send can be refused before a message exists (allowance
  reached, sending number released, empty after merge...). In the console,
  click a reason for the explanation and a CSV of exactly those recipients.
- `partial: true` means a read bound was hit on a very large or very busy
  account: delivered and replies are then floors.

Events: `batch.created`, `batch.paused`, `batch.resumed`,
`batch.canceled`, `batch.retried`, `batch.complete` (and the legacy
`broadcast.complete`, still sent for existing subscribers).

## Opt-outs are enforced per recipient

Opted-out numbers are dropped at validation, and every recipient is checked
again at send time. Anyone who replies STOP while a batch is running is
counted in `opted_out` - never texted, never silently dropped from the math.

## Limits and pacing

Up to 10,000 recipients per batch. Messages go out at about 100 per minute,
interleaved fairly with your other scheduled sends. Each message counts
against your normal quota and billing; a batch is exactly N messages.
