Batch sending

Send up to 100 emails in one request with all-or-nothing acceptance.

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: templates, tags, and scheduled_at all work per item.

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"
    }
  ]'

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:

{
  "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.

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.

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:

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 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.