> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Batch Sending

> Send up to 1,000 email messages in one Telnyx API call. Covers batch anatomy, partial-failure handling, idempotency, rate limits, and volume patterns.

Send many messages in one API call with `POST /email_messages/batch`, up to **1,000 messages** per request. After request-wide admission checks pass, each message is validated and processed independently, so one item-level validation failure does not block the rest. Request-wide gates — including account quota, reputation suspension, and sender-domain graduation — can reject the whole batch before any item is processed.

Batch sending is the right tool when you generate many distinct messages at once: a marketing campaign with per-recipient personalization, a batch of transactional notifications (invoices, receipts, shipping updates), or a nightly digest job. If you're sending the same content to many recipients, a single regular send with multiple `to` entries is simpler.

<Callout type="info">
  This guide assumes you have a Telnyx account with an API key and a sender your account is permitted to send from — normally a verified sending domain, or the shared domain during onboarding. If you don't, complete the [Quickstart](/docs/messaging/email/quickstart) first, then return here. Replace `YOUR_API_KEY` and the sender and recipient addresses in the examples with your own.
</Callout>

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_messages/batch \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "messages": [
      {
        "from": "sender@example.com",
        "to": ["recipient1@example.com"],
        "subject": "Hello 1",
        "text_body": "Message 1"
      },
      {
        "from": "sender@example.com",
        "to": ["recipient2@example.com"],
        "subject": "Hello 2",
        "text_body": "Message 2"
      }
    ]
  }'
```

## Limits

| Limit | Value | On exceed |
| - | - | - |
| Messages per batch request | 1,000 | `400` with code `10015` |
| Account daily recipient quota | Account-specific | Whole-request `429` with code `10011`; no messages are processed |
| Sender-domain graduation ceiling | Current ramp stage | Whole-request `429` with code `domain_graduation_limit_exceeded` and `Retry-After`; no messages are processed |
| Idempotency-keyed request, captured at the edge for replay | 20 MB | `413 Payload Too Large` at the edge; unkeyed requests bypass this cap |
| Per-message body (decoded) | 1 MB | Per-message `422` — the rest of the batch still processes |
| Per-message total (decoded body + attachments) | 25 MB | Per-message `422` — the rest of the batch still processes |

Each item in the `messages` array mirrors the single-send [message schema](https://api.telnyx.com/v2/openapi.json) — same fields, same validation, with `from` and `to` required — except the single-send-only reply and forward threading fields (`in_reply_to_message_id`, `reply_to_all`, and `forward_of_message_id`), which are not accepted on batch items. Templates (`template_id` with `template_variables`), `scheduled_at`, attachments, `headers`, `tags`, `metadata`, and per-item `sandbox_mode` all work as in a single send. The one batch-level addition is `sandbox_mode`, which applies sandbox mode to every message in the request and overrides any per-message setting.

<Callout type="info">
  Batch requests are processed synchronously — the response reports the outcome of every message. Large batches take proportionally longer to process; the request timeout at the edge is 55 seconds. If you're near the 1,000-message ceiling, keep payloads small, or split very heavy sends into multiple batch requests.
</Callout>

<Callout type="warning">
  The 20 MB edge cap applies only to requests carrying an `Idempotency-Key`. It is a cap on the **encoded request body**, while the per-message limits are decoded — and the two ceilings are independent. Even a single attachment-heavy message that is valid per-message (up to 25 MB decoded) can expand past 20 MB on the wire and be rejected at the edge on a keyed request. An unkeyed request bypasses the edge cap entirely, but then has no replay protection.
</Callout>

## Response

Once request-wide admission checks pass, the processed batch response uses `207 Multi-Status`, even when every item succeeds. The response contains a `data` array for created messages (which may be empty), an `errors` array for item-level failures, and a `meta` summary:

```json theme={null}
{
  "data": [
    {
      "record_type": "email_message",
      "id": "11111111-1111-1111-1111-111111111111",
      "status": "queued"
    }
  ],
  "errors": [
    {
      "index": 1,
      "code": "bad_request",
      "message": "from, to, and subject are required"
    }
  ],
  "meta": {
    "total": 2,
    "succeeded": 1,
    "failed": 1
  }
}
```

Each entry in `errors` carries the zero-based `index` of the failed message in your request array, a `code`, and a `message`. Fix the failed messages and re-send only those — the successful messages in `data` are already queued and need no re-submission.

Batch item error codes: `bad_request`, `unprocessable_entity`, `not_found`, `forbidden`, `service_unavailable`, `validation_error`, `recipient_suppressed`, and `reputation_suspended`. A per-message size violation (`unprocessable_entity`) fails only that message; the rest of the batch still processes. For the full per-code reference, see [Error Codes](/docs/messaging/email/error-codes#batch-specific-errors).

<Callout type="warning">
  **Request-wide rejections are not `207` item errors.** If the account daily quota, reputation policy, or sender-domain graduation gate rejects the request, the API returns a standard whole-request `429` error and creates zero messages. A graduation rejection uses code `domain_graduation_limit_exceeded`, carries `Retry-After`, and is atomic across every message and sender-domain identity in the batch. See [Rate Limits & Quotas](/docs/messaging/email/rate-limits#sender-domain-warm-up-ramp).
</Callout>

<Callout type="info">
  Suppression is per-recipient, not per-message. A message whose recipients are all suppressed returns the `recipient_suppressed` per-item error; other messages in the batch are unaffected. See [Suppressions](/docs/messaging/email/suppressions).
</Callout>

## Idempotency

Pass an `Idempotency-Key` HTTP header to safely retry an entire batch. Generate a unique UUID v4 for each logical batch request and reuse the same key only when retrying the identical request body:

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_messages/batch \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: 20dbec69-bc70-4fed-aec7-2a70af37a8a6" \
  -d '{
    "messages": [
      {
        "from": "sender@example.com",
        "to": ["recipient1@example.com"],
        "subject": "Hello 1",
        "text_body": "Message 1"
      }
    ]
  }'
```

The key applies to the **entire batch request** — one key, one request body, one stored response. Do not add per-message idempotency keys inside `messages`; there is no per-message key surface on the batch endpoint.

If a retried batch replays a stored response, the response includes the `Idempotent-Replayed: true` header. Reusing a key with a different body returns `422` with code `10027`. See [Idempotency](/docs/messaging/email/send-email#idempotency) for the full key lifecycle.

<Callout type="warning">
  A keyed batch larger than 20 MB is rejected at the edge with `413` before reaching the Email API. If your batch needs both replay protection and heavy payloads, keep the encoded request under 20 MB — fewer, smaller messages per request — or send the heavy batch unkeyed and make retry safety your application's responsibility.
</Callout>

## Rate limits

Batch requests are rate-limited **per account** on top of the general request limits. The response carries the Envoy draft-03 rate limit headers — `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` (seconds until the window resets) — so a well-behaved client can pace itself without guessing:

```http theme={null}
x-ratelimit-limit: 10, 10;w=60
x-ratelimit-remaining: 6
x-ratelimit-reset: 44
```

When the **edge request rate** is exceeded, the request returns `429 Too Many Requests` without an email-specific code. Honor `x-ratelimit-reset` and fall back to exponential backoff with jitter when it is absent. A whole-request API `429` can instead carry `10011`, `domain_graduation_limit_exceeded`, or `reputation_suspended`; branch on the body code rather than assuming every 429 is an edge-rate event. Exact edge tiers depend on your account level — contact support to confirm or raise your account's limits.

<Callout type="info">
  Pace your requests with `x-ratelimit-remaining` and `x-ratelimit-reset` rather than firing unbounded parallel batches. If your account's allowance supports concurrency and you need higher sustained throughput than sequential requests deliver, contact support to confirm your limits rather than fanning out against `429`s.
</Callout>

## Patterns

### Personalize at scale with templates

Batch plus [templates](/docs/messaging/email/templates) is the cleanest high-volume pattern: one template, one batch, per-recipient variables.

```bash curl theme={null}
curl -X POST https://api.telnyx.com/v2/email_messages/batch \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{
    "messages": [
      {
        "from": "billing@example.com",
        "to": ["ada@example.com"],
        "template_id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123",
        "template_variables": { "first_name": "Ada", "invoice_total": "$42.00" }
      },
      {
        "from": "billing@example.com",
        "to": ["grace@example.com"],
        "template_id": "7a7c1a2b-1111-4c72-8c21-2bbf3d40c123",
        "template_variables": { "first_name": "Grace", "invoice_total": "$17.50" }
      }
    ]
  }'
```

### Schedule a batch

Every message in a batch can carry its own `scheduled_at` — mixed immediate and scheduled sends in one request are fine. See [Schedule a send](/docs/messaging/email/send-email#schedule-a-send).

### Track outcomes per message

Each created message in `data` returns its own `id`. Use those IDs with `GET /email_messages/{id}` and `GET /email_messages/{id}/events` for per-message tracking, or configure [webhooks](/docs/messaging/email/webhooks-events) to receive delivery and engagement events for every message in the batch.

<Callout type="info">
  The batch response's `data` entries are creation records — `status: "queued"` is the starting point, not the delivery outcome. Track delivery through events, exactly as with single sends.
</Callout>

### Retry only the failures

The `errors[].index` mapping makes partial retry mechanical. This complete example sends a batch, then resubmits only the failed messages with a **new** idempotency key:

```python theme={null}
import os
import time
import uuid
import requests

url = "https://api.telnyx.com/v2/email_messages/batch"
headers = {
    "Authorization": f"Bearer {os.environ['TELNYX_API_KEY']}",
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
}

messages = [
    {"from": "sender@example.com", "to": [f"user{i}@example.com"],
     "subject": f"Hello {i}", "text_body": f"Message {i}"}
    for i in range(100)
]

response = requests.post(url, json={"messages": messages}, headers=headers)

if response.status_code == 429:
    # A request-wide 429 processes zero messages. Branch on the body code:
    # graduation uses Retry-After; edge rate limits use x-ratelimit-reset;
    # daily quota waits until midnight UTC; reputation suspension is not retryable.
    try:
        body = response.json()
    except ValueError:
        body = {}
    code = (body.get("errors") or [{}])[0].get("code")
    if code == "reputation_suspended":
        raise SystemExit("Sending suspended: remediate domain reputation before retrying")
    if code == "10011":
        raise SystemExit("Account daily quota exhausted: queue work until midnight UTC")
    if code == "domain_graduation_limit_exceeded":
        wait_seconds = int(response.headers["Retry-After"])
    else:
        wait_seconds = int(response.headers.get("x-ratelimit-reset", "60"))
    time.sleep(wait_seconds + 1)
    response = requests.post(url, json={"messages": messages}, headers=headers)

if response.status_code != 207:
    raise SystemExit(f"Batch request failed: {response.status_code} {response.text}")

# Only a 207 reaches here. Per-item errors are indexed failures of
# individual messages; outer errors were handled above.
result = response.json()
failed_indexes = [e["index"] for e in result.get("errors", [])]

if failed_indexes:
    retry_headers = {**headers, "Idempotency-Key": str(uuid.uuid4())}
    retry_messages = [messages[i] for i in failed_indexes]
    retry = requests.post(url, json={"messages": retry_messages}, headers=retry_headers)
    print(f"Retried {len(retry_messages)} failed messages: {retry.status_code}")
```

## What's next

* [Sending Email](/docs/messaging/email/send-email) — full payload reference for each message
* [Rate Limits & Quotas](/docs/messaging/email/rate-limits) — all size and rate ceilings
* [Templates](/docs/messaging/email/templates) — Liquid templating with per-recipient variables
* [Webhooks & Events](/docs/messaging/email/webhooks-events) — delivery and engagement events for every message
