---
title: "Send a batch"
description: "`emails.send_batch`: up to 100 messages, results per item."
url: "https://openemail.uk/docs/ruby/emails/batch"
area: "Ruby"
category: "Emails"
---

# Send a batch

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

## emails.send_batch

**send_batch.rb**

```
invoices = [
  {number: "INV-1042", email: "ada@example.com"},
  {number: "INV-1043", email: "grace@example.com"}
]

messages = invoices.map do |invoice|
  {from: "billing@acme.com", 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)}"
  end
end
```

`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

- `emails` (Array<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_key` (String): 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_key` (String): Sends the batch with this key instead of the client’s.

**Each message in emails**

- `from` (String or Hash, required): 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`.
- `to` (String, Hash or Array, required): 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.
- `cc` (String, Hash or Array): Defaults to none, and counts against the same 50-address total as `to` and `bcc`.
- `bcc` (String, 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.
- `replyTo` (String 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.
- `subject` (String): 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.
- `html` (String): 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`.
- `text` (String): 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.
- `headers` (Hash): `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.
- `attachments` (Array<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.
- `threadId` (String): 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.
- `draftId` (String): 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.
- `template` (Hash): 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.
- `scheduledAt` (Time, 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.
- `cancellableForSeconds` (Integer): 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.
- `tracking` (Hash): `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.
- `tags` (Hash): 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.
- `translate` (Hash): 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

- `items` (Array<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.
- `sent` (Integer): 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.
- `failed` (Integer): 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**

- `index` (Integer): 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.
- `status` (String): `ok` or `error`. `ok` carries `email`, `error` carries `error`, and no item carries both.
- `email` (Hash): 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.
- `error` (Hash): 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**

- `type` (String): 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`.
- `code` (String): 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`.
- `message` (String): One sentence written for a person, naming the offending value where there is one. Not a stable identifier. Switch on `code`.
- `param` (String): 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.
