Skip to main content

Direct send: one-off messages, batch or single

Some sends are not campaigns. A support follow-up to one customer, a one-time notification to a finite list — no named audience, no schedule, no per-variant analytics to justify a campaign object. Outbound → Direct send is the entry point for those sends. It is a chooser page with two paths:
  • Send to a list (CSV) — the batch SMS wizard at Messages → Batch SMS: upload a recipient list, render a template per row, review cost, and send everything in one batch.
  • Send to one contact — a picker that searches your contacts, shows every channel the contact is reachable on, and opens that channel’s composer with the recipient prefilled.
Both paths run the same suppression, opt-out, and consent enforcement as a campaign. The difference is bookkeeping: a direct send writes message history rows and nothing else — no campaign object, no draft lifecycle, no analytics rollup.

When direct send instead of a campaign

If a recurring weekly CSV run or per-variant measurement appears, the send has outgrown direct send — move it to a campaign and follow Send a campaign end-to-end.

The chooser page

Open Outbound → Direct send. Two cards lead:
  1. Send to a list (CSV) links straight to the batch wizard — the next section covers it.
  2. Send to one contact opens the contact picker.
Below the cards, a shortcut row jumps directly into the SMS, WhatsApp, or Email composer if you already know the channel and intend to address it yourself.

Single contact: pick a contact, choose a channel

The Send to one contact card opens a picker:
  1. Search your contacts by name, phone, or email.
  2. Select a contact. The picker lists every identifier on the contact — a contact with both a phone number and an email shows both — and offers every channel it can route to: SMS for a phone number, Email for an email address.
  3. Choose the channel and continue. The picker routes to that channel’s composer with the recipient prefilled: /messages/sms?to=<phone>&contactId=<id> or /messages/email?to=<email>&contactId=<id>. The to parameter prefills the To field; contactId records which contact row you came from.
A contact with no phone number and no email is not selectable — the picker labels it “no reachable channel” and disables the row. WhatsApp does not accept a prefilled recipient from this picker; reach it from the WhatsApp shortcut on the chooser page (WhatsApp sends need a connected WABA sender).

Batch: CSV to a finished send

The Send to a list (CSV) card opens Messages → Batch SMS, a three-step wizard:
  1. Upload — drop a .csv (or paste numbers directly), or load recipients from an existing contact list.
  2. Preview — review the per-row rendered message, segment counts, and the cost breakdown resolved per destination.
  3. Send — the wizard submits the whole batch as a single POST /messages/batch request and reports each recipient’s outcome.
The full review, pricing, and failure-retry walkthrough is in Batch SMS: send a one-off message to many recipients. The sections below cover the parts that matter most when you arrive through direct send.

CSV rules for batch sends

The wizard’s Upload step understands three recipient sources — CSV, pasted numbers, or a saved contact list — and only one source is active at a time: choosing a second source replaces the first rather than combining both. The CSV contract:
  • One phone column is required. The header row is auto-detected. Recognized phone-column headers include phone, phone_number, to, number, mobile, telephone, and tel. Recipients are E.164 numbers; whitespace, dashes, and parentheses are stripped before validation.
  • Per-row message column switches to per-row mode. If the CSV has a message, body, text, content, sms, or sms_body header, every row carries its own body and the shared compose step is skipped. Otherwise the wizard applies one shared template with {{variable}} placeholders, and your extra CSV columns (for example name) bind those variables per row.
  • Invalid and duplicate rows are excluded, not fatal. A partial number like +123 is flagged and dropped; duplicates are merged with a count. Excluded rows are never priced and never sent — the review footnote discloses exactly how many were skipped.
  • Rejections stay per-row. A post-upload rejection (validation, quota, sender-validation, country gate set in your compliance controls) flips only that row to failed; the rest of the batch still sends.
Keep a batch to one message theme. When the rows stop resembling one another — different purposes, different opt-in bases — that is two batches, or a campaign.

Retries: idempotency and remapping

The batch is submitted as one POST /messages/batch call; failures come back as per-row outcomes, never as a silent whole-batch loss:
  • Every recipient is persisted before it is attempted. Each recipient becomes a message row first — a rejected row is visible in the failed-recipients table immediately with its reason, not dropped. The response is 200 when every recipient succeeded and 207 (Multi-Status) when at least one failed alongside at least one success.
  • Retry failed rows only. The failed-recipients table lists each row with a remapped, friendly reason and the raw machine-readable error_code as a badge. For carrier-side or transient failures, the messaging retry policy applies server-side — retry the failed recipients; already-sent rows are not re-sent when you re-submit the batch envelope with the same idempotency behaviour.
  • Batch envelope rate limit. One batch is one request, capped at 30 batch envelopes per minute per tenant (X-RateLimit-Bucket: messages-batch on the response headers tells your SDK when to back off). The per-recipient cost — carrier throughput, frequency caps, quota — is enforced inside the send pipeline, not by this limit.

Batch over the API

Everything the wizard does on the screen, POST /api/v1/messages/batch does over the wire. The envelope below is the scripted equivalent of a CSV upload: a shared template with {{variable}} placeholders, and per-row bindings that map your extra CSV columns onto those variables. Recommendation: automate only when the send is truly recurring — a one-off upload is faster through the wizard. Once you find yourself re-uploading the same shape, the API is the winning move.

A batch request

This is the CSV contract restated as JSON. body is the shared template; each recipient carries a variables bag that binds the placeholders, exactly the way your extra CSV columns (name, order) bound the wizard’s variables. A recipient that carries its own body switches that row to per-row mode — the same switch the message CSV column triggers.
The response envelope is the same data/meta pair every endpoint returns. data carries batch_id, a results[] row per recipient, and a summary with the counters.

200 vs 207 — read the per-row outcome

The HTTP status tells you whether anything failed; the results[] array tells you which row and why. You get 200 OK only when every recipient succeeded. The moment at least one row fails alongside a success — or every row fails — the response is 207 (Multi-Status) and each failed row carries a friendly error_message (the remapped reason a human reads) plus the raw, machine-readable error_code a program branches on. A 207 with one succeeded and one failed row:
status is one of sent, queued, scheduled, delivered, or failed. error_code and error_message are present only on failed rows. Retry the failed ones only — the next section shows the batch knows how to skip a row you already sent.

Re-submit with the same idempotency key

Send the same payload again with the same Idempotency-Key and already-sent rows are not re-sent — the re-run skips them and only attempts the rows that failed. This is the retry loop to build: treat a 207 as “retry the failed subset”, reuse the idempotency key, and the send pipeline keeps the retry from becoming a double-send.
Never build a retry by resubmitting a fresh idempotency key — that is a new batch, and it will re-send every row.

The batch cap and the back-off receipt

The batch envelope is rate-limited to 30 per minute per tenant. Every response — success or failure — carries the bucket name so your client knows which cap it is up against:
Exceed the bucket and the platform returns 429 Too Many Requests with a structured receipt your back-off loop should honor:
Read retry_after (seconds) before retrying. Any SDK that understands X-RateLimit-Bucket: messages-batch can back off cap-specifically; a hardcoded sleep ignores which limit actually fired. The per-recipient costs — carrier throughput, frequency caps, quota — are still enforced inside the pipeline and are not what this cap protects.

Python SDK

The same batch from Python. The batch route is not on the SDK’s per-channel client.messages convenience layer, so call request(...) directly — that is the documented path for a shape the wrappers do not cover. The SDK auto-generates an Idempotency-Key on every POST; pass your own to control the retry-skip semantics above.
A read-only key fails these calls with 403; the key must carry the messages:write scope, and SMS batches from an API key must name an explicit from sender.

Permissions and limits per channel

Direct send is a send, so it is gated like every other send in the workspace:
  • Role. Sending — from the batch wizard, the single-contact composer, or the API — requires an owner, admin, or developer role. Members with a read-only (viewer / light-seat) role cannot fire a direct send.
  • API key scope. API-driven sends need a key minted with the messages:write scope; a read-only key is rejected with 403.
  • Per-channel limits. POST /messages/batch accepts up to 10,000 recipients per call (resolved per tenant) across sms, whatsapp, email, rcs, viber, instagram, messenger, line, and telegram. The dashboard batch wizard drives SMS; the same route handles the other channels over the API.
  • Sender and channel setup. SMS needs a sender — an owned number, an alphanumeric sender ID, or a sender pool. Email needs a verified sending address. WhatsApp needs a connected WABA sender. A contact with no reachable identifier for the channel (for example an SMS-only contact sent to the Email composer) has nothing to address — the composer starts empty.
  • Wallet and attestation. The batch wizard’s confirm step is gated on a wallet check and a compliance attestation that recipients are authorized and opt-outs honored.

How direct send relates to campaigns

Both surfaces end at the same send pipeline. What a campaign adds is everything around the send: Guardrails do not relax because a send is one-off. Direct sends pass through the same tenant controls as campaign sends:
  • Suppression lists and channel-level opt-outs are checked per recipient — a suppressed or opted-out recipient is held back either way (see Opt-out lists and suppression).
  • Frequency caps count a direct send toward the recipient’s budget (see Frequency caps).
  • Quiet hours and send gating defer or block sends inside a recipient-local quiet window, direct send or campaign alike (see Quiet hours configuration).
All of these are controls you own at tenant level — configure them, and direct send respects them exactly as a campaign does.

See also