Skip to main content

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