> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Batch-send model — persist-then-attempt per-recipient pipeline

> How POST /messages/batch persists every recipient as a pending row before the send pipeline runs, which limits gate the batch, and what each recipient row can land as — so operator totals reconcile.

# Batch-send model: persist-then-attempt

`POST /messages/batch` sends one message body to many recipients in a single request. It is a **fan-out with per-recipient receipts**: every recipient becomes a row in your messages table, and the response reports each recipient's outcome. This page explains the pipeline that makes that true, the limits that govern a batch, and what a recipient row can land as. For the dashboard wizard, see the [Batch SMS guide](/guides/messages-batch-sms).

## Why batch is a separate object

A single send (`POST /messages/sms`, `/messages/whatsapp`, and the other channel endpoints) creates one message row and attempts one delivery. Issuing N single sends for N recipients works, but the recipient list lives only on your side: a request rejected before any row is written (validation, quota, sender, compliance) leaves nothing in the listing, and your side-counts drift from what the operator totals show.

The batch endpoints make the recipient list itself a first-class object on the platform:

* **One request, one sender, one body** — the request shapes collapses N HTTP calls to one, with a per-batch recipient cap instead of N idempotency keys.
* **Per-recipient rows, always** — persist-then-attempt (below) records every recipient, including the rejected ones, so success and failure counters reconcile with the listing.
* **Per-recipient receipts** — the response returns a `results[]` array with one entry per recipient, plus a `summary` of succeeded / failed / scheduled.

Batch is deliberately lighter than a campaign. A campaign is a launch object with an audience, draft lifecycle, dry-run, holdouts, and analytics — right when the send is a managed program. Single-send semantics are untouched either way: the batch pipeline runs each recipient through the same `MessagesService.sendMessage` path the per-channel endpoints call, so provider selection, pricing, and compliance gates behave identically between the two surfaces.

## The persist-then-attempt pipeline

The defining invariant: **every recipient is persisted before it is attempted, and a rejection flips its row to failed instead of throwing.**

1. **Pre-insert.** The platform computes a body per recipient (per-row `body` overrides the shared `body`; Liquid `{{ variables }}` are resolved at this step) and bulk-inserts every recipient as a `messages` row with `status='pending'` in one SQL transaction. The pre-insert is all-or-nothing: if the insert fails, no recipient was processed and the request returns 500 with no partial state. Rows larger than 5,000 recipients are chunked across multiple statements, each its own transaction.
2. **Attempt.** Each pending row runs through the same send pipeline the single-send endpoints use, with the pre-inserted row's id threaded in. The row create inside that pipeline upserts on conflict (`INSERT … ON CONFLICT (id) DO UPDATE`), atomically promoting the pending row to its post-validation shape rather than inserting a duplicate.
3. **Fail visibly.** When the send pipeline throws at a pre-create gate — validation, fraud guard, quota, sender validation, country compliance — the batch layer catches it and marks the pre-inserted row `failed` with `error_code` and `error_message` copied from the thrown error. The row is never left invisible.
4. **Report.** The endpoint returns a per-recipient outcome entry (`message_id`, `to`, `status`, and for failures `error_code` + `error_message`) and a summary with succeeded / failed / scheduled counts. The dashboard wizard renders the same counters plus per-recipient reason chips.

Attempts run sequentially, not in parallel: the per-tenant send-rate limiter caps throughput at the carrier-allocated rate, and a burst of parallel sends only adds throttle noise. Stuck pending rows (a batch interrupted mid-attempt) are reaped by platform maintenance and flipped to `failed` so they stop skewing operator totals.

## Limits that govern a batch

The platform default cap is **10,000 recipients per batch** — sized so a runaway blast cannot saturate the per-tenant send-rate limiter for the next hour. An organization can opt in to a higher cap through the batch settings on the organization (`batch.cap_override_enabled` plus `batch.max_recipients`); the override is clamped to the platform safety ceiling of **1,000,000** recipients and can never drop below the platform default. Any batch beyond the resolved cap is rejected with a 422 before anything is persisted. Bigger or recurring sends belong in the [campaigns](/guides/campaign-end-to-end) module, which paginates and respects the carrier TPS allocation.

Three channel gates apply before any row is attempted:

* **Accepted channels.** The batch endpoint accepts the messaging subset of the single-send channel set: `sms`, `whatsapp`, `email`, `rcs`, `viber`, `instagram`, `messenger`, `line`, `telegram`. Voice and fax are excluded (a batch carries a text body; those channels need per-recipient media or a call flow). Channel choice never changes provider routing — outbound still terminates through the Devotel wholesale softswitch.
* **Address shape.** For phone-bearing channels the recipient `to` must pass a two-layer check: structural E.164 shape, then numbering-plan deliverability — so a partial or wrong-length entry like `+123` fails as a clean per-recipient `VALIDATION_ERROR` rather than reaching the carrier. For `email`, the address must pass an email-shape check. Platform-scoped IDs (Telegram chat IDs, LINE, Messenger, Instagram) pass through to provider-side validation.
* **Wallet floor.** Before any row is pre-inserted, the platform checks that the balance covers a conservative per-recipient floor for the channel (free channels are exempt; the per-message check at send time remains the authoritative gate). An underfunded batch fails fast with a 402 instead of dispatching partway through the list.

Per-organization batch settings are tenant-owned configuration: raising the cap is an explicit opt-in per organization, never a platform-wide default.

## What a recipient row can land as

Each recipient starts as `pending` and resolves to one of five outcomes, returned per recipient in the `results[]` array and aggregated in the `summary`:

| Outcome     | Meaning                                                                                          |
| ----------- | ------------------------------------------------------------------------------------------------ |
| `sent`      | The channel accepted the message.                                                                |
| `queued`    | The message is staged for dispatch (pre-carrier)                                                 |
| `scheduled` | `scheduled_at` was set; the worker queue releases it at the wall-clock target.                   |
| `delivered` | A carrier delivery receipt (DLR) already arrived during the attempt.                             |
| `failed`    | Rejected by a pre-create gate or by the carrier; `error_code` + `error_message` name the reason. |

The response envelope is HTTP 200 when every recipient succeeded, and HTTP 207 (Multi-Status) when any recipient failed. Failed rows carry a friendly, redacted message — raw internal error text never reaches the client. After the batch, DLRs continue to transition rows asynchronously (Delivered / Submitted / Failed in the messages listing), and failed rows keep their reason so the operator totals stay consistent.

## What the model is not

* **Not a campaign.** No audience, draft lifecycle, dry-run, holdouts, ROI attribution, or analytics rollup. Campaign-level tracking lives in the campaigns module.
* **Not group messaging.** Group messaging (`POST /messages/group`, MMS) is a specific carrier parity fan-out on the MMS channel; the batch model is the general per-recipient-receipts fan-out across the messaging channels. See [Group messaging model](/concepts/group-messaging-model) for the distinction between fan-out, routing claims, and labels.
* **Not a broadcast list.** There is no stored recipient list per batch — every request carries its full recipient set. Identity resolution and contact merge are separate models (see [Data model](/concepts/data-model)).

## See also

* [Batch SMS guide](/guides/messages-batch-sms) — the dashboard wizard end-to-end
* [Messaging API reference](/api-reference/endpoints/messaging) — request/response schemas for batch and every channel
* [Group messaging model](/concepts/group-messaging-model) — fan-out versus routing claims versus labels
* [Send a campaign end-to-end](/guides/campaign-end-to-end) — when you need audience, dry-run, holdouts, and analytics
* [Message status DAG](/concepts/message-status-dag) — status transitions after the batch lands
