---
title: "Send a batch of messages"
method: POST
path: "/v1/messages/batch"
tags: ["Messages"]
---

# Send a batch of messages

`POST /v1/messages/batch`

## Request

- Base URL: `https://api.mails.ai`
- URL: `https://api.mails.ai/v1/messages/batch`
- Auth: HTTP bearer

**Requires scope:** `send`. Sends up to 100 messages in one request. Per-message failures are returned inline in `data[]` and do not fail the batch; the HTTP status is still 201. Each failure is the error a single send would answer: a message a send limit refused carries `resets_at` and `retry_after_seconds`, one the email service would not take for now (503 `upstream_unavailable`) `retry_after_seconds`, and a plan refusal `upgrade_url`. With an `Idempotency-Key`, a batch in which no message was stored and no mail went out frees the key, so sending it again with the same key sends it; any other batch's answer is kept on the key. A scheduled message is its result once stored, even if announcing it then fails: with a key the batch answers that error, and the retry with the same key gets the batch (201) and raises the `message.scheduled`. A batch cannot send attachments: an item carrying `attachments` refuses the whole batch with 422 `invalid_field` (param `attachments`), so send that message on its own with POST /v1/messages.

## Headers

- `Idempotency-Key` string

## Request body

- object[]
  - `agent` string: Which agent sends this message: its name or `agt_` id. Optional, as on `POST /v1/messages`: leave it out and `from` names the agent, or the workspace's single agent sends. A key tied to one agent always sends as that agent and may leave this out. Required only when the workspace has several agents and `from` names none of them; without it, that message fails with `missing_field`. In a workspace with no active or paused agent, the first one is created when the key may create agents (the `manage` scope); otherwise that message fails with `insufficient_scope`. An unknown agent fails only that message, with `agent_not_found` (`agent_archived` for an archived one), and a key tied to one agent fails it with `insufficient_scope` for any other, named here or in `from`.
  - `from` string: The sender you want, as on `POST /v1/messages`. Without `agent`, `billing`, `billing@acme.com` and `Acme <billing@acme.com>` all mean the agent called `billing`, and never a different agent. If there is none, it is created when the key may create agents (the `manage` scope) and your plan has room; otherwise only that message fails, with `insufficient_scope` or `plan_limit_exceeded`. Beside `agent`, only its display name is used: `agent` decides the sender. Either way a display name in it goes on the From line, a scheduled item's too. The address that sent comes back as `from` on the message's result.
  - `to` union, required: Who the message goes to: one address, or a list of 1 to 50.
    - string, email
    - string[]
  - `cc` string[]: Addresses to copy, up to 50. Every recipient sees them.
  - `bcc` string[]: Addresses to blind-copy, up to 50. They receive the message but appear in none of its headers.
  - `subject` string, required: Subject line, 1 to 998 characters.
  - `body_text` string: Plain-text body, up to 1,000,000 characters. At least one of `body_text` and `body_html` is required, or their aliases `text` and `html`.
  - `body_html` string: HTML body, up to 1,000,000 characters. With `body_text` as well, the email carries a plain-text part and an HTML part.
  - `html` string: Alias of `body_html`: used when `body_html` is left out, ignored when both are sent.
  - `text` string: Alias of `body_text`: used when `body_text` is left out, ignored when both are sent.
  - `reply_to` string, email: The address replies should go to. Left out, replies go to the agent's `receiving_address`.
  - `in_reply_to_message_id` string: Id of the message this one answers (`msg_…` or `rcv_…`), named in the email's `In-Reply-To` header. The message joins its thread when the same agent sent or received it. Left out, it starts a new thread.
  - `references` string[]: Message-IDs for the `References` header. Write one from another system with its angle brackets (`<…>`); a value without them is read as a mails.ai message id.
  - `metadata` object: Your own key-value strings, kept with the message and returned by `GET /v1/messages/{id}`; they are not part of the email. Keys up to 64 characters, values up to 500.
  - `scheduled_at` string, date-time: Send this message at this time instead of now: an ISO 8601 UTC time ending in `Z`. A time in the past sends at once.
  - `tags` object[]: Up to 10 name-value labels, kept with the message and included in its `message.sent` event; they are not part of the email.
    - `name` string, required: Tag name: 1 to 256 letters, digits, `_` or `-`.
    - `value` string, required: Tag value, up to 256 characters; it may be empty.
  - `list_unsubscribe` boolean: Opt in to RFC 8058 one-click unsubscribe: the message carries `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers, so the recipient's mail app shows an Unsubscribe button. One click suppresses the address for your whole workspace: every later send to it from any of your agents is refused with `recipient_suppressed` until you allowlist it (`POST /v1/suppression/allow`). Turn it on for newsletters, digests and other recurring mail. Leave it off (the default) for password resets, sign-in links, receipts and anything else the recipient must keep receiving. Needs exactly one `to` recipient and no `cc` or `bcc`.

## Response `201`

Per-message results (success or error objects) plus the submitted batch size.

- object
  - `data` union[], required: One result per submitted message, in request order: the accepted message, or an `error` object for one that was refused. A refused message does not stop the others.
    - union
      - SentMessage
        - `id` string, required: Message id (`msg_…`), the `{id}` in `/v1/messages/{id}`.
        - `agent_id` string: Id of the agent that sent the message (`agt_…`).
        - `from` string, email: The agent address this was sent as — the resolved `from`. Read it back when you pass `from` as a naming hint instead of an explicit `agent`: it is the receipt for which identity the API picked. On a verified custom domain the message goes out from this exact address. On the shared mails.ai domain it goes out from `<name>.<workspace>@send.mails.ai`, and replies come back through `<name>.<workspace>@in.mails.ai`.
        - `thread_id` string: The thread the message belongs to (`thrd_…`).
        - `in_reply_to_message_id` string: The message this one answers: the message replied to or forwarded, or the `in_reply_to_message_id` the send set. Present on reply responses and on the single-message GET, when set.
        - `to` string[]: The To recipients, always a list. On a reply they come from the original: its Reply-To or sender for a received message, its `to` for one of your sent messages.
        - `cc` string[]: Cc recipients. Always present on reply responses (`[]` when none); on the list and the single-message GET, present when there are any.
        - `bcc` string[]: Bcc recipients. Present only on the list and the single-message GET, when there are any.
        - `subject` string: The subject as sent. On a reply, `Re: ` and the original's subject, unless that already starts with `Re:`; on a forward, the `subject` you gave or `Fwd: ` and the original's.
        - `body_text` string: The plain-text body as sent; on a forward it includes the quoted original. Present only on the single-message GET, when there is one.
        - `body_html` string: The HTML body as sent. Present only on the single-message GET, when there is one.
        - `classifier_score` number: The cold-email firewall's score for the message, from 0 (clean) to 1 (cold or spam). Present on the send response, the list and the single-message GET.
        - `status` 'scheduled' | 'sending' | 'sent' | 'delivered' | 'delayed' | 'bounced' | 'complained' | 'rejected' | 'canceled', required: `sent` once handed off; `delivered`, `bounced` or `complained` when the receiving side reports it (with several recipients the latest report wins). `delayed` when a transient (soft) bounce came back: the receiving server has not taken the message yet; it is not a bounce, does not count toward the bounce rate, raises `message.delayed` (sent only to endpoints that list it) and may still become `delivered`; with no delivery confirmed within 5 days of sending, it becomes `bounced`. `sending` only while a scheduled send is going out; `rejected` when the send failed, or a scheduled send was refused when it came due; `canceled` when a scheduled send was stopped. A live send only to the reserved test address reply@test.mails.ai is `delivered` at once, with a `message.delivered` event, since it is never transmitted; with a test key it stays `sent`, as every test-mode send does. Either way its automatic reply arrives as reply.received (at most 3 replies in one thread; a 4th message there gets none).
        - `refused_recipients` string[]: The recipients the email service refused when the message was sent, while it took the message for the others: it went to everyone else, and not to these. The message is still a send, so sending it again with the same `Idempotency-Key` gives this answer back and sends nothing; to reach them, check the addresses and send them a new message. Present only when there were any: on the send, batch, reply and forward responses, the list, the single-message GET and the message's `message.sent` event. When every recipient is refused, nothing is sent and the send fails instead.
        - `cost_usd` number: USD cost of the send. 0 for scheduled or test-mode sends.
        - `ses_message_id` string: The sending provider's id for the message, which its delivery, bounce and complaint reports refer to; `test-<message id>` for a test-mode send. Present only on the list and the single-message GET, once the message has been handed off.
        - `attachments` AttachmentMeta[]: Name, type and size of each file the send carried; the files themselves are not kept. Present when there were any, on the send response, the list and the single-message GET.
          - `filename` string, required: The file name, as given in the send's `attachments`.
          - `content_type` string, required: The MIME type given in the send's `attachments`. A file fetched from its `path` without one takes the type the URL answered with, or `application/octet-stream` when it named none.
          - `size_bytes` integer, required: Size of the file in bytes: `content_base64` decoded, or the bytes fetched from `path`.
          - `content_id` string: Only on a file shown inside the HTML body: the id its `cid:` names.
        - `references` string[]: The `References` chain kept with the message: the `references` the send passed, or on a reply the ids of the earlier messages it answers. Present only on the single-message GET, when there is one.
        - `metadata` object: The `metadata` the send passed; a scheduled send refused when it came due also carries `rejected_reason` and `rejected_at`. Present only on the single-message GET, when there is any.
        - `tags` Tag[]: The tags the message was sent with. Present when there are any, on send, batch and reply responses, the list and the single-message GET.
          - `name` string, required: The tag's name, as given on the send, reply or draft.
          - `value` string, required: The tag's value, as given; it may be an empty string.
        - `sent_at` string, date-time: When the message was handed off for sending: at once for an immediate send, when it came due for a scheduled one. Present only on the list and the single-message GET, once that has happened.
        - `delivered_at` string, date-time: When delivery was confirmed; with several recipients, the time of the latest report. Present only on the list and the single-message GET, once a delivery is confirmed.
        - `scheduled_at` string, date-time: The time a scheduled send was set for. On send, batch and reply responses it is present only when that time was in the future; the list and the single-message GET keep it after the message has gone out or been canceled.
        - `canceled_at` string, date-time: When the scheduled send was canceled. Present only on the list and the single-message GET, for a canceled message.
        - `bounced_at` string, date-time: When a bounce was last reported for the message; a soft bounce, which marks it `delayed`, does not set it. Present only on the single-message GET.
        - `complained_at` string, date-time: When a recipient's complaint about the message was last reported. Present only on the single-message GET.
        - `forwarded_from` string: Source message id (forward responses only).
        - `test_mode` boolean: True for a message sent with a test key (`mk_test_…`): nothing was transmitted and nothing was billed.
        - `classifier_warning` object: Present only on a test-mode send that a live key would refuse: the cold-email firewall's verdict, reported instead of thrown. Nothing was transmitted.
          - `code` 'cold_email_prohibited', required: Always `cold_email_prohibited`, the error code a live key would get.
          - `message` string, required: The verdict in words: why a live key would refuse the message, what would pass instead, and that nothing was transmitted.
          - `score` number, required: The firewall's score for the message, from 0 (clean) to 1 (cold or spam).
          - `reason` string, required: What the message was classified as, such as `cold_outreach`, `spam_pattern` or `phishing_lure`.
        - `created_at` string, date-time: When the message was created; for a scheduled send, when it was scheduled, not when it went out.
      - Error
        - `error` object, required: The error, as every error response carries it: `type`, `code`, `message`, `param` and `request_id`, plus `errors` on a validation failure, `resets_at` and `retry_after_seconds` on a 429, `retry_after_seconds` on any other error with a `Retry-After`, and `upgrade_url` on a 402. A failed item in a batch send is the same error, and `retry_after_seconds` is how it says how long to wait, since an item has no headers of its own.
          - `type` 'invalid_request_error' | 'authentication_error' | 'permission_error' | 'rate_limit_error' | 'resource_error' | 'payment_error' | 'abuse_error' | 'api_error', required: High-level error category.
          - `code` 'invalid_field' | 'missing_field' | 'missing_param' | 'invalid_param_value' | 'method_not_allowed' | 'invalid_idempotency_key' | 'duplicate_resource' | 'slug_invalid' | 'slug_reserved' | 'slug_taken' | 'agent_name_conflict' | 'body_too_large' | 'missing_authorization' | 'missing_api_key' | 'invalid_api_key' | 'expired_api_key' | 'revoked_api_key' | 'invalid_access_token' | 'email_not_verified' | 'insufficient_scope' | 'connected_app_not_allowed' | 'sso_required' | 'workspace_mismatch' | 'agent_not_owned' | 'rate_limit_exceeded' | 'daily_limit_exceeded' | 'hourly_limit_exceeded' | 'monthly_limit_exceeded' | 'agent_not_found' | 'agent_paused' | 'agent_archived' | 'resource_not_found' | 'recipient_suppressed' | 'recipient_not_allowed' | 'domain_not_verified' | 'payment_required' | 'payment_method_failed' | 'subscription_canceled' | 'billing_state_inconsistent' | 'free_tier_exceeded' | 'plan_limit_exceeded' | 'cold_email_prohibited' | 'agent_suspended' | 'new_workspace_fanout_exceeded' | 'resource_cap_exceeded' | 'workspace_cap_exceeded' | 'feature_not_enabled' | 'test_mode_required' | 'live_mode_required' | 'unknown_test_address' | 'workspace_not_approved' | 'spam_pattern_detected' | 'classifier_rejected' | 'high_injection_score' | 'complaint_threshold_exceeded' | 'bounce_threshold_exceeded' | 'internal_error' | 'internal_server_error' | 'upstream_error' | 'upstream_unavailable' | 'service_unavailable', required: Stable machine-readable error code.
          - `message` string, required: Human-readable explanation.
          - `param` string, nullable: The offending request field, when applicable.
          - `request_id` string: Echo of the X-Request-Id header for support correlation.
          - `errors` FieldError[]: On a validation failure, EVERY offending field (not just the first). The top-level code/message/param mirror errors[0] for backward compatibility.
            - `code` 'invalid_field' | 'missing_field' | 'missing_param' | 'invalid_param_value' | 'method_not_allowed' | 'invalid_idempotency_key' | 'duplicate_resource' | 'slug_invalid' | 'slug_reserved' | 'slug_taken' | 'agent_name_conflict' | 'body_too_large' | 'missing_authorization' | 'missing_api_key' | 'invalid_api_key' | 'expired_api_key' | 'revoked_api_key' | 'invalid_access_token' | 'email_not_verified' | 'insufficient_scope' | 'connected_app_not_allowed' | 'sso_required' | 'workspace_mismatch' | 'agent_not_owned' | 'rate_limit_exceeded' | 'daily_limit_exceeded' | 'hourly_limit_exceeded' | 'monthly_limit_exceeded' | 'agent_not_found' | 'agent_paused' | 'agent_archived' | 'resource_not_found' | 'recipient_suppressed' | 'recipient_not_allowed' | 'domain_not_verified' | 'payment_required' | 'payment_method_failed' | 'subscription_canceled' | 'billing_state_inconsistent' | 'free_tier_exceeded' | 'plan_limit_exceeded' | 'cold_email_prohibited' | 'agent_suspended' | 'new_workspace_fanout_exceeded' | 'resource_cap_exceeded' | 'workspace_cap_exceeded' | 'feature_not_enabled' | 'test_mode_required' | 'live_mode_required' | 'unknown_test_address' | 'workspace_not_approved' | 'spam_pattern_detected' | 'classifier_rejected' | 'high_injection_score' | 'complaint_threshold_exceeded' | 'bounce_threshold_exceeded' | 'internal_error' | 'internal_server_error' | 'upstream_error' | 'upstream_unavailable' | 'service_unavailable', required: Stable machine-readable code for this field.
            - `message` string, required: Human-readable explanation for this field.
            - `param` string, nullable, required: The offending request field (dot-path), or null.
          - `resets_at` string, date-time, nullable: On every 429: when the limit lets the request through again (UTC). Null when waiting will not help, for a cap that only an upgrade or a deletion lifts.
          - `retry_after_seconds` integer, nullable: On every 429: the seconds until `resets_at`, null when it is null. On any other error that says how long to wait, such as a 503 `upstream_unavailable`: the seconds its `Retry-After` header gives.
          - `upgrade_url` string, uri: On every 402: the dashboard page where the plan can be changed, `https://app.mails.ai/billing`.
  - `batch_size` integer, required: How many messages the request held; `data` has one entry for each.

## Other responses

- `400`: Invalid request — malformed JSON or a field failed validation. Also `feature_not_enabled` when this server has a feature switched off (custom domains before they are available).
- `401`: Missing or invalid API key.
- `403`: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
- `409`: Conflict — duplicate resource, an in-progress idempotent request, a draft or scheduled message that started sending while it was being changed, or a thread deleted for good while one of its messages is being sent (`duplicate_resource`); or an agent name more than one agent has (`agent_name_conflict`: use the agent's id); or a status change on an archived agent, since archiving cannot be undone (`agent_archived`).
- `422`: Unprocessable — agent paused, recipient suppressed, a recipient at test.mails.ai other than reply@test.mails.ai (`unknown_test_address`), or abuse guard.
- `429`: Rate or quota limit exceeded. The body's `resets_at` and `retry_after_seconds` say when the request can succeed. A `Retry-After` header is sent only when that is 60 seconds or less; a longer wait (an hourly, daily or monthly cap) sends none, so retry at `resets_at` or raise the limit instead of sleeping.
- `500`: Internal server error.

---

[API](https://skmtc.dev/mails/apis/mails-ai-api.md) · [All operations](https://skmtc.dev/mails/apis/mails-ai-api/llms.txt) · [OpenAPI document](https://skmtc.dev/mails/apis/mails-ai-api/revisions/4d4930a8c33b?raw)
