Skip to content

Batch sends and scheduled sends

One hundred messages per batch, partial success semantics, and scheduling — including how to reschedule or cancel a message while it is still queued.

SendingIntermediate9 min readUpdated
YOU'LL NEED

01One hundred per request

POST /v1/emails/batch takes an array of up to a hundred message objects — each one exactly the same shape you would send to /v1/emails on its own — and accepts them as a hundred separate messages.

TL;DR

A batch of a hundred is a hundred different emails to a hundred different people, not one email with a hundred recipients. The second thing is not even possible here.

A batch of 100
  • A hundred distinct messages, each with its own body
  • Each recipient gets their own id, their own events and their own unsubscribe token
  • One bad item does not touch the other ninety-nine
One message, many recipients
  • Capped at fifty addresses across to, cc and bcc — a hundred is refused
  • Everyone shares one message id, one open pixel, one unsubscribe token and one delivery outcome
  • Usually a mistake the moment the content differs by so much as a first name
POST /v1/emails/batch
[
  { "from": "Acme <[email protected]>", "to": ["[email protected]"],
    "subject": "Invoice 4821",   "html": "<p>Due 30 Sep.</p>" },
  { "from": "Acme <[email protected]>", "to": ["[email protected]"],
    "subject": "Invoice 4822",   "html": "<p>Due 30 Sep.</p>" }
]  // … up to 100 items

The hundred is not a tier and there is no plan that raises it. A batch is one atomic unit of work with one request timeout, and an unbounded array cannot be given a sensible one — the honest choices are a fixed ceiling or a request that sometimes dies half-processed with no way to find out what happened. Above a hundred, split into multiple requests; the endpoint is cheap and the messages are independent anyway.

ONE IDEMPOTENCY KEY COVERS THE WHOLE BATCH
Set Idempotency-Key once for the request and each item derives its own key from it by index — your-key:0, your-key:1, and so on. A retried batch is therefore idempotent per item, which is the behaviour you expect when you set one header for one call. It also means the order of the array is load-bearing across a retry: shuffle it and the keys no longer line up with the same messages.

02One bad item does not fail the batch

The response is { "data": [ … ] }, one entry per item, in the order you sent them. Nothing about a bad item at position seven touches the other ninety-nine.

TL;DR

An accepted item carries an id. A rejected one carries its index and a typed error. Your success number is the count of entries carrying an id — not the HTTP status.

POST /v1/emails/batch · 200
$ curl -s -X POST … /v1/emails/batch -d @invoices.json
{ "data": [
{ "id": "email_2Nq8x…" },
{ "id": "email_2Nq8y…" },
{ "index": 2, "error": { "name": "invalid_to_address",
"message": "`bob@@example.com` is not a valid address." } },
{ "id": "email_2Nq8z…" }
] }
# 3 accepted, 1 rejected, HTTP 200 for all of it
Entry shapeMeansWhat you do with it
{ "id": … }That item was accepted and spooled.Store the id against whatever your application calls this message.
{ "index": …, "error": … }That item was rejected. index is its position in the array you sent, so an error entry still identifies itself once you have pulled the failures into their own collection.Fix those items and resend only those.

The array is positional: data[2] is always the third item you sent.

The alternative design — reject the whole batch on the first bad item — sounds safer and is worse in practice. It forces the caller to diff two arrays to work out what happened, it turns one typo in a CSV import into ninety-nine messages that never went, and it produces a retry loop that resends the ninety-nine good ones every time while never fixing the one that is broken.

03Scheduling a send

Add scheduled_at to any send — single or batched — and the message is stored with status scheduled instead of being queued immediately. You get the same response shape and the same id; the only difference is when the send path picks it up.

TL;DR

The field accepts more than an ISO timestamp, and the parser echoes back how it read what you sent — which is the whole defence against the classic off-by-an-hour, because you can assert on it in a test.

You sendRead asMeaning
2026-10-02T14:30:00ZisoExact, unambiguous, and what you should send from code.
in 90mrelativeOffset from the moment the request is accepted.
in 2h30mrelativeUnits compose, and each has spelled-out forms: s/sec/seconds, m/min/minutes, h/hr/hours, d/day/days, w/week/weeks.
30mrelativeA bare duration is accepted as relative — enough SDK users send it.
tomorrow 9amnaturalClock applied in UTC, not in your browser’s zone.
today 14:30natural“today” is accepted beside “tomorrow”, and a 24-hour clock parses.
tomorrownaturalNo clock given, so 09:00 UTC. The interpretation is returned to you.
nownaturalEquivalent to omitting scheduled_at entirely.
next Tuesday-ishrejectedAnything the parser cannot read confidently is a validation_error naming scheduled_at, never a guess.
BoundErrorWhy it is there
30 days aheadscheduling_too_farA send further out than a month is nearly always a units bug — milliseconds where seconds were meant, or a year typo — and on the rare occasion it is intentional, the content is stale by the time it goes.
60 seconds in the pastscheduling_in_pastA minute of slack, so that clock skew between your machine and the edge is not an error. Earlier than that is refused.

04Rescheduling and cancelling

Two operations, one precondition. PATCH /v1/emails/:id with a new scheduled_at moves a message; DELETE /v1/emails/:id cancels it. Both require the message to still be in the scheduled state, and both return a not-found error otherwise.

TL;DR

scheduled is the only state that is still yours. There is no state after it in which either call does anything.

1 · Move it
PATCH /v1/emails/:id
$ curl -X PATCH … /v1/emails/email_2Nq8x -d '{"scheduled_at":"in 6h"}'
{ "object": "email", "id": "email_2Nq8x", "scheduled_at": "2026-09-11T15:31:04.118Z" }

The response echoes the resolved absolute time, so a relative input is confirmed as a timestamp rather than left for you to recompute.

2 · Cancel it
DELETE /v1/emails/:id
$ curl -X DELETE … /v1/emails/email_2Nq8x
{ "object": "email", "id": "email_2Nq8x", "status": "canceled" }
3 · Or find out you are too late
$ curl -X DELETE … /v1/emails/email_2Nq8x
404 not_found
Only a scheduled message can be canceled. This one has already been queued or sent.

The cancel is two steps and both of them matter. The database row is moved to canceled under a condition that only matches a still-scheduled row — so two concurrent cancels cannot both succeed, and a cancel racing the scheduler loses cleanly rather than half-applying — and then the timer holding that message is told to drop it. Doing only the first would leave a fired timer looking for a message that is no longer sendable; doing only the second would leave a row claiming it is still going out.

05When to use a broadcast instead

They are not two sizes of the same feature, and the difference shows up in how each one behaves when it gets big.

TL;DR

A batch is for distinct messages you already have in hand; a broadcast is for one message to an audience you have not enumerated.

Batch
  • Unit of work: one HTTP request
  • Size: 100 messages, hard
  • You supply: every message body
  • Progress: the response, once
  • Pause and resume: no such concept
Broadcast
  • Unit of work: a long-running job you can leave
  • Size: the audience, whatever it is
  • You supply: one template and a segment
  • Progress: a cursor you can watch
  • Pause and resume: the same operation as crash recovery

A broadcast is split into thirty-two fixed ranges, each with its own coordinator and a monotonic cursor, and there is deliberately no counting pass anywhere in it. A count is a full scan that returns a number which is stale the instant it is computed, and it gets slower in exact proportion to how expensive it already was. Because progress is a cursor rather than a count, pausing and resuming is the same operation the system already performs after a crash — which is why it is trustworthy, rather than a feature bolted on beside the happy path.

So: clumps of transactional mail are batch work — a nightly run of invoices, a queue of receipts, fifty password resets from an incident. Anything where the recipient list is a query rather than a list, and where you would want to stop it halfway and look, is a broadcast.

What just happened

You can now push a hundred distinct messages in one request and read the per-item results rather than a single pass-or-fail, and you can put a message in the future and move or cancel it while it is still queued. The boundary to keep in mind is that last clause: scheduled is the only state that is still yours. Once a message has been picked up for sending, PATCH and DELETE both return a not-found error, and no amount of API design changes the fact that mail which has left cannot be recalled.

Common questions

Read next

YOUR ACCOUNT, YOUR MAIL

Nothing to sign up for. Just deploy it.

Every guide on this site describes software you run yourself.