---
title: "Batch sending"
description: "Send up to 100 emails in one request with all-or-nothing acceptance."
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

`POST /v1/emails/batch` accepts up to 100 messages in a single request. The batch is atomic: either every message is accepted and you get every id, or nothing is persisted and you get one error.

## Send a batch

The request body is a top-level JSON array of 1–100 email objects. Each object takes exactly the same fields as a single [`POST /v1/emails`](/api-reference/emails/send): templates, tags, and `scheduled_at` all work per item.

<Tabs>
<Tab title="curl">

```bash
curl -X POST https://api.mailfully.com/v1/emails/batch \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "from": "orders@mail.acme.com",
      "to": "you@yourcompany.com",
      "subject": "Your order shipped",
      "html": "<p>Your order is on its way.</p>",
      "tags": [{ "name": "category", "value": "order-shipped" }]
    },
    {
      "from": "orders@mail.acme.com",
      "to": "you@yourcompany.com",
      "subject": "Your order shipped",
      "text": "Order 4212 shipped.",
      "scheduled_at": "2026-07-10T09:00:00Z"
    }
  ]'
```

</Tab>
<Tab title="Node SDK">

```typescript
const { data, error } = await mailfully.emails.batch([
  {
    from: "orders@mail.acme.com",
    to: "you@yourcompany.com",
    subject: "Your order shipped",
    html: "<p>Your order is on its way.</p>",
    tags: [{ name: "category", value: "order-shipped" }],
  },
  {
    from: "orders@mail.acme.com",
    to: "you@yourcompany.com",
    subject: "Your order shipped",
    text: "Order 4212 shipped.",
    scheduledAt: "2026-07-10T09:00:00Z",
  },
]);
```

</Tab>
</Tabs>

Replace `you@yourcompany.com` with an address you own before running this.

Success is a `202` with one id per input object, in request order:

```json
{
  "data": [
    { "id": "01JZFYK2N8Q4T7W9E1R3M5X0AB" },
    { "id": "01JZFYK2NAB3C5D7E9F1G3H5J7" }
  ]
}
```

Correlate ids by position: `data[0]` is the first object you sent, `data[1]` the second.

## All-or-nothing acceptance

There are no per-item results. Every object is validated and prepared before anything is written, and the first invalid item aborts the entire batch with a single `422 validation_error` whose `param` prefixes the offending item's index (`[i]` or `[i].<field>`, for example `[37].to`). The API persists and enqueues none of it.

A body that isn't a JSON array fails with `422 validation_error`: `Batch body must be a JSON array of email objects.` An array outside 1–100 items fails the same way.

<Note>
One caveat to the `[i]` prefix: an unknown `template.id` in any item fails the batch with a plain `404 not_found` (`Template not found.`) that does not name the offending index. All-or-nothing still holds. Nothing is persisted. If a batch 404s, check each item's `template.id`. This is current behavior.
</Note>

## Gates run once per batch

The org-level accept gates evaluate the batch as a unit, in this order:

1. **Verified domain**: a live batch with no verified sending domain is rejected with `403 unverified_domain_required`.
2. **Spend cap, then monthly ceiling**: the monthly check projects `current + N` for a batch of N; crossing either limit returns `429 spend_cap_exceeded` or `429 monthly_quota_exceeded` (live only).
3. **Daily cap**: the batch consumes one unit per message; a denial part-way through returns `429 daily_quota_exceeded` with a `Retry-After` header, and the units checked before the denial stay consumed.

One denial rejects the whole batch. A batch is also a single request, so it makes one pass through rate limiting and idempotency rather than N.

## Idempotency

The `Idempotency-Key` header covers the entire batch — one key for the whole array, with no per-item keys:

```typescript
const { data, error } = await mailfully.emails.batch(inputs, {
  idempotencyKey: "import-2026-07-08",
});
```

Semantics match the single send:

- Same key + same body within 24 hours replays the original `202` verbatim, including the same ids.
- Same key + a different array is rejected with `409 invalid_idempotent_request`.
- Two concurrent requests with the same key: one wins, the other gets `409 concurrent_idempotent_requests`.
- Only 2xx responses are cached. A rejected batch releases the key, so you can fix the offending item and retry with the same key.

See [Idempotency](/concepts/idempotency) for the full contract.

## Batch or loop?

Use the batch endpoint when:

- The messages are generated together (an import, a digest run, a fan-out of the same notification) and partial acceptance would leave you reconciling which half went out.
- You want atomic accept semantics: all ids or one error.
- You want fewer requests through rate limiting and the accept gates.

Loop single sends instead when:

- You need per-message error isolation. In a batch, one invalid address aborts all 100; looped sends fail independently.
- You need per-message idempotency keys, for example one key per order id. A batch key covers the whole array.
- Messages are created at different moments rather than as one unit of work.

Caps to keep in mind: 100 items per batch, and each item allows up to 50 combined `to`, `cc`, and `bcc` recipients.

## Related

- [`POST /v1/emails/batch`](/api-reference/emails/send-batch): full request and response reference
- [Idempotency](/concepts/idempotency)
- [Rate limits](/concepts/rate-limits)
