---
title: "Send a batch of emails in one request"
method: POST
path: "/send-mail/batch"
tags: ["Sending"]
---

# Send a batch of emails in one request

`POST /send-mail/batch`

Send up to 100 independent messages in a single call. Each message uses the same body schema and the same send path as `POST /send-mail`, so auth, validation, rate limiting, and idempotency apply per message. Messages are processed independently: one failing does not fail the others — every message gets an entry in `results` carrying either `data` (on success) or `error` (on failure), with the original `index`.

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `messages` object[], required — The messages to send. Each item is a standard send-mail request body.
    - `from` string, required — RFC 5322 From header. The sender domain must be a verified outbound domain for your organization.
    - `to` string, required — Recipient address. Recipient eligibility depends on your account's outbound entitlements.
    - `subject` string, required — Subject line for the outbound message
    - `body_text` string — Plain-text message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes.
    - `body_html` string — HTML message body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes.
    - `in_reply_to` string — Message-ID of the direct parent email when sending a threaded reply.
    - `references` string[] — Full ordered message-id chain for the thread.
    - `attachments` object[] — Inline attachments. Send requests with attachments to https://api.primitive.dev/v1/send-mail. Combined raw decoded attachment bytes must be at most 31457280.
      - `filename` string, required — Attachment filename. Control characters are rejected.
      - `content_type` string — Optional MIME content type. Control characters are rejected.
      - `content_base64` string, required — Base64-encoded attachment bytes.
    - `wait` boolean — When true, wait for the first downstream SMTP delivery outcome before returning.
    - `wait_timeout_ms` integer — Maximum time to wait for a delivery outcome when wait is true. Defaults to 30000.

## Response `200`

Per-message results. The request as a whole returns HTTP 200 even when individual messages fail; inspect each `results` entry.

- object
  - `success` boolean, required
  - `data` object, required
    - `count` integer, required — Number of messages in the batch.
    - `results` object[], required
      - `index` integer, required — Zero-based position of this message in the request array.
      - `success` boolean, required
      - `data` object
        - `id` string, required — Persisted sent-email attempt ID.
        - `status` 'queued' | 'submitted_to_agent' | 'agent_failed' | 'gate_denied' | 'unknown' | 'delivered' | 'bounced' | 'deferred' | 'wait_timeout', required — Lifecycle status of a sent_emails row. Possible values: - `queued`: pre-call INSERT; the outbound agent has not yet replied. - `submitted_to_agent`: agent accepted; `queue_id` is set. - `agent_failed`: agent rejected; `error_code` and `error_message` carry the reason. - `gate_denied`: a recipient-scope gate denied the send; the agent was never called. The `gates` array carries the denial detail. /send-mail returns 403 in this case so callers see the denial synchronously; /sent-emails additionally records the row for historical lookup, which is when this status appears in a listing. - `unknown`: terminal indeterminate; the on-box log poller couldn't classify the receiver's response. - `delivered` / `bounced` / `deferred` / `wait_timeout`: terminal delivery outcomes (see DeliveryStatus).
        - `from` string, required — Bare from-address actually written on the wire. Echoed on every success branch so callers can confirm what went out, particularly useful for the /emails/{id}/reply path where `from` is server-derived from the inbound's recipient when the caller doesn't override. For sends where the caller passed a from-header that included a display name (e.g. `"Acme Support" <support@acme.test>`), this field is the parsed bare address (`support@acme.test`). The display name was sent on the wire intact; this field just makes the address easy to compare against allowlists.
        - `queue_id` string, nullable, required — Message identifier assigned by Primitive's OUTBOUND relay (the box that signs your mail and submits it to the receiving MTA). NOT the receiver's queue id. The receiver may also report its own queue id in `smtp_response_text` (e.g. `"250 2.0.0 Ok: queued as 99D111927CDA"` from a Postfix receiver). Those two ids refer to different mail systems and are NOT comparable. Treat `queue_id` as Primitive-internal and the receiver's id as remote-system-internal. Null on rows that never reached the relay (queued, gate_denied, agent_failed before signing).
        - `accepted` string[], required — Recipient addresses accepted by the relay.
        - `rejected` string[], required — Recipient addresses rejected by the relay.
        - `client_idempotency_key` string, required — Effective idempotency key used for this send.
        - `request_id` string, required — Server-issued request identifier for support and tracing.
        - `content_hash` string, required — Stable hash of the canonical send payload.
        - `delivery_status` 'delivered' | 'bounced' | 'deferred' | 'wait_timeout' — Narrower enum covering only the four terminal delivery outcomes returned to a synchronous `wait: true` send. On the SendMailResult shape, `delivery_status` is always equal to `status` whenever both are present (i.e. on terminal-state replays and live wait=true responses). The two fields exist so callers that want to type-narrow on "this is a delivery outcome" can pattern-match against the four-value enum without handling the broader SentEmailStatus value set (which also covers `queued`, `submitted_to_agent`, `agent_failed`, `gate_denied`, `unknown`). On async-mode and pre-terminal responses, `delivery_status` is absent and only `status` is populated. Use `status` if you want a single field that's always present.
        - `smtp_response_code` integer, nullable — SMTP response code from the first downstream delivery outcome when wait is true.
        - `smtp_response_text` string — SMTP response text from the first downstream delivery outcome when wait is true.
        - `idempotent_replay` boolean, required — True when the response replays a previously-recorded send keyed by `client_idempotency_key` (same key, same canonical payload). False on a fresh send and on gate-denied responses. Lets callers branch on cache state without diffing fields.
      - `error` object
        - `code` string, required
        - `message` string, required

## Other responses

- `400` — Invalid request parameters
- `401` — Invalid or missing API key
- `429` — Rate limit exceeded

---

[API](https://skmtc.dev/primitive/apis/primitive-api.md) · [All operations](https://skmtc.dev/primitive/apis/primitive-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/primitive/primitive-api/revisions/c2299d28022c/schema)
