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.
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 asummaryof succeeded / failed / scheduled.
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.- Pre-insert. The platform computes a body per recipient (per-row
bodyoverrides the sharedbody; Liquid{{ variables }}are resolved at this step) and bulk-inserts every recipient as amessagesrow withstatus='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. - 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. - 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
failedwitherror_codeanderror_messagecopied from the thrown error. The row is never left invisible. - Report. The endpoint returns a per-recipient outcome entry (
message_id,to,status, and for failureserror_code+error_message) and a summary with succeeded / failed / scheduled counts. The dashboard wizard renders the same counters plus per-recipient reason chips.
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 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
tomust pass a two-layer check: structural E.164 shape, then numbering-plan deliverability — so a partial or wrong-length entry like+123fails as a clean per-recipientVALIDATION_ERRORrather than reaching the carrier. Foremail, 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.
What a recipient row can land as
Each recipient starts aspending and resolves to one of five outcomes, returned per recipient in the results[] array and aggregated in the summary:
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 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).
See also
- Batch SMS guide — the dashboard wizard end-to-end
- Messaging API reference — request/response schemas for batch and every channel
- Group messaging model — fan-out versus routing claims versus labels
- Send a campaign end-to-end — when you need audience, dry-run, holdouts, and analytics
- Message status DAG — status transitions after the batch lands