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

# Send a message

`POST /v1/messages`

## Request

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

**Requires scope:** `send`. Sends an email from one of your agents. With a `mk_test_` key the message is stored and its `message.sent` event reaches your webhooks (marked `test_mode: true`), but no email is sent and nothing is billed. Quota, suppression and rate-limit refusals are skipped, and a message the cold-email firewall would refuse comes back 201 with `classifier_warning` instead. Send to `reply@test.mails.ai` to get an automatic reply within about a second; it answers at most 3 times in one thread, so a 4th message there gets no reply. It is the only address at test.mails.ai: a send naming any other there is refused with 422 `unknown_test_address`, and nothing is sent.

## Headers

- `Idempotency-Key` string

## Request body

- SendMessageRequest: Provide at least one of `body_text` or `body_html`. Total decoded attachment size must be ≤ 25 MB.
  - `agent` string: Which agent sends this — its name or `agt_` id. Optional: omit it and we use the workspace's single agent, or provision one named from `from` when the key may create agents (the `manage` scope); a workspace with no active or paused agent gets its first one that way even without `from`. Required only when the workspace has several agents and no `from` names one of them. When it is given it decides the sender: a `from` beside it picks no agent and creates none. A key tied to one agent always sends as that agent and may leave this out; naming another agent here, or in `from` when this is left out, is a 403 `insufficient_scope`.
  - `from` string: The sender you want, as a handle or address — `billing`, `billing@acme.com` and `Acme <billing@acme.com>` all mean the agent called `billing`. It SELECTS that agent: resolved if it exists. If it doesn't, it is created when the key may create agents (the `manage` scope) and your plan has room for it; otherwise the send is refused, with 403 `insufficient_scope` when the key may not create agents and 402 `plan_limit_exceeded` when your plan has no room. An address at a domain you have verified is matched whole: `billing@yourdomain.com` means the agent at exactly that address, created on your domain on the same terms if it doesn't exist yet. It is never resolved to a different agent, and it cannot spoof a sender — the address on the From line is always derived from the agent's own identity. Beside an explicit `agent` it selects nothing: `agent` decides the sender. Either way a display name in it, like `Acme` above, goes on the From line, a scheduled send's too. The address that actually sent comes back as `from` on the response.
  - `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 send 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.
  - `attachments` object[]: Up to 10 files, 25 MB in total. Each is given by its bytes (`content_base64`) or by an https `path` that mails.ai downloads when it sends, and one with a `content_id` is shown inside the HTML body. Not accepted with a future `scheduled_at`: a scheduled send cannot carry attachments yet, so that combination is refused with 400 `invalid_field`.
    - `filename` string, required: The file's name, as the recipient sees it. A file shown inside the HTML can reach the recipient named by its `content_id` instead (see `content_id`).
    - `content_base64` string: The file's bytes, base64-encoded. Give this or `path`, not both.
    - `path` string: An https URL that mails.ai downloads when it sends, instead of `content_base64`. It must answer within 10 seconds, after at most 3 redirects, each to https, and must not be a private, loopback, link-local or cloud metadata address. A file that cannot be fetched refuses the send with 400 `invalid_field`, its `param` naming it (`attachments.0.path`).
    - `content_type` string: The file's MIME type, such as `application/pdf`. Required with `content_base64`. With `path` it may be left out, and the type the URL answers with is used.
    - `content_id` string: Show the file inside the HTML body instead of as an attachment: `<img src="cid:logo">` in `html` shows the file whose `content_id` is `logo`. Letters, digits and `. _ - @ + = $`, up to 127 characters, unique within the message. Mail with no `html` has nowhere to show it, so the file is sent as an ordinary attachment under its `filename`. Shown in the HTML, it can reach the recipient named by its `content_id`: one of the email services mails.ai sends through names such a file by the `cid:` the HTML uses.
  - `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 at this time (ISO 8601) instead of now. A time in the past sends at once. A future time cannot be combined with `attachments`. When a passing failure stops a scheduled send (one of ours, or the email service not taking more mail for now), it is tried again a minute or more later, up to 3 tries in all, and then fails with `message.failed`; a scheduled draft is tried the same way and fails with `draft.failed`.
  - `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`

Message accepted (sent or scheduled).

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

## 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.
- `402`: Payment required: a plan limit (`plan_limit_exceeded`) or a feature the current plan does not include (`feature_not_enabled`). The error object carries `upgrade_url`, the dashboard page where the plan can be changed.
- `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`).
- `404`: Resource not found in this workspace, or the agent a send names is archived (`agent_archived`).
- `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.
- `502`: The email service the mail goes out through did not confirm it took it (`upstream_error`), so it is recorded as not sent. The message is a fixed sentence naming the request id to quote to support@mails.ai; the service's own error is never returned. When the service refuses every recipient as undeliverable the answer is 422 `recipient_suppressed` instead, and when it is only not taking more mail for now, 503 `upstream_unavailable`.
- `503`: Temporarily unavailable, and nothing was done. On a send: the email service is not taking more mail right now (`upstream_unavailable`), so nothing was sent; send it again after the seconds in `Retry-After`.

---

[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)
