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.
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 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
- 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 itemsThe 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.
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.
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.
$ 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 shape | Means | What 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.
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 send | Read as | Meaning |
|---|---|---|
| 2026-10-02T14:30:00Z | iso | Exact, unambiguous, and what you should send from code. |
| in 90m | relative | Offset from the moment the request is accepted. |
| in 2h30m | relative | Units compose, and each has spelled-out forms: s/sec/seconds, m/min/minutes, h/hr/hours, d/day/days, w/week/weeks. |
| 30m | relative | A bare duration is accepted as relative — enough SDK users send it. |
| tomorrow 9am | natural | Clock applied in UTC, not in your browser’s zone. |
| today 14:30 | natural | “today” is accepted beside “tomorrow”, and a 24-hour clock parses. |
| tomorrow | natural | No clock given, so 09:00 UTC. The interpretation is returned to you. |
| now | natural | Equivalent to omitting scheduled_at entirely. |
| next Tuesday-ish | rejected | Anything the parser cannot read confidently is a validation_error naming scheduled_at, never a guess. |
| Bound | Error | Why it is there |
|---|---|---|
| 30 days ahead | scheduling_too_far | A 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 past | scheduling_in_past | A 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.
scheduled is the only state that is still yours. There is no state after it in which either call does anything.
$ 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.
$ curl -X DELETE … /v1/emails/email_2Nq8x{ "object": "email", "id": "email_2Nq8x", "status": "canceled" }
$ curl -X DELETE … /v1/emails/email_2Nq8x404 not_foundOnly 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.
A batch is for distinct messages you already have in hand; a broadcast is for one message to an audience you have not enumerated.
- 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
- 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.