Skip to the documentation
Ruby

Send a batch

`emails.send_batch`: up to 100 messages, results per item.

emails.send_batch

send_batch.rb
invoices = [  {number: "INV-1042", email: "[email protected]"},  {number: "INV-1043", email: "[email protected]"}] messages = invoices.map do |invoice|  {from: "[email protected]", to: invoice[:email], subject: "Invoice #{invoice[:number]}", text: "Your invoice is attached."}end result = client.emails.send_batch(messages, idempotency_key: "invoices:2026-09") puts "#{result.sent} sent, #{result.failed} failed" result.items.each do |item|  if item[:status] == "error"    warn "#{item[:index]} #{item.dig(:error, :code)} #{item.dig(:error, :message)}"  else    puts "#{item[:index]} #{item.dig(:email, :id)}"  endend

send_batch takes an Array of message Hashes, each shaped exactly like the body of emails.send, and returns an OpenEmail::BatchResult. Its items hold one Hash for each message, in order, each either ok with its message or error with the envelope that message would have been refused with. Nothing rolls back, so a failed count above 0 is a list to act on rather than a reason to resend the batch.

One idempotency key covers the batch and the server extends it for each item, so a retried batch replays every message rather than collapsing them onto the first. Send the same Array in the same order when you retry it: an item that moved is bound to another position’s key and comes back as an idempotency_key_reuse error.

A refused message does not raise. Only a problem with the batch as a whole raises: an empty Array, more than 100 messages, more than 10 carrying translate, a key or scope failure, or a server fault. A server fault partway through comes after the earlier items have gone, and the client retries it with the same key, which replays those items instead of sending them twice.

The items are sent one after another inside a single request, so a large batch of immediate sends takes noticeably longer than one send. Keep the client’s timeout: generous.

Parameters: emails.send_batch

emailsArray<Hash>required
One to 100 messages, sent as `{"emails": [...]}` and accepted one at a time in the order given. Each goes through the same handling as `emails.send`, so a lone recipient is wrapped, a Time becomes an instant and attachment bytes are encoded. An empty Array, more than 100, or more than 10 messages carrying `translate` refuses the whole call with a `validation_error` on `emails`. A missing `emails:send` scope and a malformed `idempotency_key:` refuse the whole call too, before a single message is sent.
idempotency_keyString
Deduplicates the batch across processes. The client attaches a freshly generated key on every call in any case, so its own retries never double-send, and the server extends whichever key it gets for each item as `key/0`, `key/1` and so on, slash-separated with a character your own key may not contain, so one key over a hundred messages cannot collapse them onto the first.
api_keyString
Sends the batch with this key instead of the client’s.

Each message in emails

fromString or Hashrequired
The sender, as a bare address, `Name <addr@host>` or a Hash with `email` and `name`. There is no fallback sender and the key must be allowed this address. A refusal fails that one item, as a `permission_error` with code `from_address_forbidden`.
toString, Hash or Arrayrequired
At least one recipient, and a lone one is wrapped in an Array by the client. At most 50 addresses across `to`, `cc` and `bcc` combined, counted per message rather than across the batch.
ccString, Hash or Array
Defaults to none, and counts against the same 50-address total as `to` and `bcc`.
bccString, Hash or Array
Defaults to none, and counts against the same 50-address total. `Bcc` is one of the names `headers` may not set, so this is the only way to blind-copy. The header form would undo the per-recipient envelope that keeps the address blind.
replyToString or Hash
Where replies go. It is applied after `headers`, so it overwrites a `Reply-To` you also set there rather than adding a second one.
subjectString
At most 998 characters, the RFC 5322 line limit, and defaults to an empty String. An empty subject falls through to the template’s own when `template` supplies one.
htmlString
The HTML part, at most a million characters, and the part recipients see when both bodies are given. One of `html`, `text`, `template` or `draftId` is required, and an item with none of them fails as a `validation_error` on `html`.
textString
The plain-text part, at most a million characters. Both may be sent, and every transport on this path builds one body from one String, so `html` wins where there is one.
headersHash
`X-*`, `List-*`, Reply-To, Precedence, Auto-Submitted, Importance, Priority and Feedback-ID only. Anything the transport sets itself (From, To, Bcc, Subject, Message-ID, the DKIM and ARC headers) is refused as `reserved_header` rather than quietly dropped. Values are at most 998 characters and may not carry CR, LF or NUL, because a second line is a second header.
attachmentsArray<Hash>
At most 20 files per message, with inline files totalling 5 MB once decoded, counted per message and not per batch. `content` is base64 on the wire. Pass the bytes as a binary String, an IO or a Pathname and the client encodes them. A Hash with only `fileId` names a file already in the workspace and is not counted against the inline cap.
threadIdString
Reply into an existing thread, at most 256 characters. The transport writes In-Reply-To and References from it, which is what makes the reply land in the conversation rather than beside it.
draftIdString
Send a saved draft’s content under this envelope, at most 256 characters. The recipients, subject and headers built here are what go on the wire.
templateHash
Render a stored template server-side, by id (`tpl_…`) or slug, with `version` pinning a revision and `props` and `slots` filling it. Settled once, when the item is accepted, and refused alongside `html` or `text` and alongside `draftId`, since each of those is a second answer to what the message contains.
scheduledAtTime, DateTime or String
A Time or a DateTime, an ISO 8601 instant, or a duration like `PT1H`, at least a second in the future and at most 365 days out. A Ruby Date means midnight UTC on that day. Items schedule independently, so one batch can hold a hundred different send times.
cancellableForSecondsInteger
An undo window in seconds on an immediate send, from 0 to 900 and defaulting to 0. Anything above 0 is refused alongside `scheduledAt` on the same item, since a scheduled message is already cancellable until it goes.
trackingHash
`opens` and `clicks`, each optional and each overriding the setting for this message alone. A key you leave out follows the address the message is sent from (or the catch-all that caught it), which is off unless that address turned it on.
tagsHash
At most 10 labels, with keys of 1 to 64 characters drawn from `A-Za-z0-9_-` and values up to 256. Echoed back on the message and never interpreted: `emails.list` filters on `status:`, `from:`, `broadcast_id:` and the schedule window and nothing else, so a tag is something to read off a message you already hold rather than a way to find it.
translateHash
Send this item in another language, settled at accept time so the words that were approved are the words that go out. At most 10 items in one batch may carry it: each spends several model calls and the items run in order, so a larger batch would be killed mid-send. Over that the whole call is refused as `too_many_items` on `emails`, before anything is sent.

Response: OpenEmail::BatchResult

itemsArray<Hash>
One Hash for each message, in the order you sent them. Nothing rolls back, so this is a record of what happened to each message rather than a report on a transaction. The API answers 207 whether every message was accepted, some were or none was, so the call returns either way and each item’s `status` is what to branch on.
sentInteger
How many items were ACCEPTED, which is not the same as how many left. An item can be `ok` and still carry an `email` whose `status` is `failed` or `partial`, because a transport that refuses the message after the row exists is a delivery outcome and not a rejected request.
failedInteger
How many items carry an `error`. A count above 0 is a list to act on rather than a reason to resend the batch. The accepted messages have already gone.

Each item

indexInteger
The position this item’s message held in the Array you sent. Carried as a key as well as an order, so code that filters or sorts `items` can still say which message failed.
statusString
`ok` or `error`. `ok` carries `email`, `error` carries `error`, and no item carries both.
emailHash
The accepted message, on an `ok` item only, in the same shape a single send returns. Its `replayed` is true when the derived `Idempotency-Key` matched a send that already existed, so nothing new was sent and this is the original message. It carries no `tracking` key, because engagement is reported later and there is nothing to report at accept time.
errorHash
Why this one message was refused, on an `error` item only. It is the API’s error envelope minus `docUrl` and `requestId`: those describe the request, and the request as a whole succeeded.

An item’s error

typeString
The category to branch on: `validation_error`, `permission_error`, `not_found_error`, `conflict_error` and the rest. The set is frozen and will not grow, unlike `code`.
codeString
The specific failure: `from_address_forbidden`, `invalid_email_address`, `too_many_recipients`, `reserved_header`, `message_too_large`, `unknown_parameter`. Open and additive, so treat a code you do not recognise as its `type`.
messageString
One sentence written for a person, naming the offending value where there is one. Not a stable identifier. Switch on `code`.
paramString
The field that was refused, as a dotted path within THAT message: `to.0`, `from`, `attachments`. Absent when the failure names no field, and never prefixed with the batch position, which is what `index` is for.