import { createClient } from "@mailsai/sdk";
const client = createClient(); // reads MAILS_API_KEY
const result = await client.messages.send({
agent: "hello",
to: "jordan@example.com",
subject: "Your order #10473 has shipped",
body_text: "Your order #10473 is on its way.",
});
console.log(result);from mailsai import create_client
client = create_client() # reads MAILS_API_KEY
result = client.send(
agent="hello",
to="jordan@example.com",
subject="Your order #10473 has shipped",
body_text="Your order #10473 is on its way.",
)
print(result)curl -X POST 'https://api.mails.ai/v1/messages' \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"agent": "hello",
"to": "jordan@example.com",
"subject": "Your order #10473 has shipped",
"body_text": "Your order #10473 is on its way."
}'mails send --to jordan@example.com \
--subject "Your order shipped" \
--text "It is on its way."
mails send --from hello \
--to a@example.com,b@example.com --subject "Hello" \
--html-file ./hello.html --attach ./invoice.pdf{
"name": "mails_send",
"arguments": {
"agent": "hello",
"to": "jordan@example.com",
"subject": "Your order #10473 has shipped",
"body_text": "Your order #10473 is on its way."
}
}{
"id": "msg_01JZX8K3M9Q4P7VN2YB6RTDC0E",
"agent_id": "agt_01JZ9F8H7KQ2M3N4P5R6S7T8U9",
"thread_id": "thrd_01JZX8K3M9Q4P7VN2YB6RTDC11",
"to": ["jordan@example.com"],
"subject": "Your order #10473 has shipped",
"classifier_score": 0.02,
"status": "sent",
"cost_usd": 0.0004,
"tags": [{ "name": "category", "value": "shipping" }],
"test_mode": false,
"created_at": "2026-06-24T17:32:08.421Z"
}201 Message accepted (sent or scheduled).
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.Send a message
import { createClient } from "@mailsai/sdk";
const client = createClient(); // reads MAILS_API_KEY
const result = await client.messages.send({
agent: "hello",
to: "jordan@example.com",
subject: "Your order #10473 has shipped",
body_text: "Your order #10473 is on its way.",
});
console.log(result);from mailsai import create_client
client = create_client() # reads MAILS_API_KEY
result = client.send(
agent="hello",
to="jordan@example.com",
subject="Your order #10473 has shipped",
body_text="Your order #10473 is on its way.",
)
print(result)curl -X POST 'https://api.mails.ai/v1/messages' \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"agent": "hello",
"to": "jordan@example.com",
"subject": "Your order #10473 has shipped",
"body_text": "Your order #10473 is on its way."
}'mails send --to jordan@example.com \
--subject "Your order shipped" \
--text "It is on its way."
mails send --from hello \
--to a@example.com,b@example.com --subject "Hello" \
--html-file ./hello.html --attach ./invoice.pdf{
"name": "mails_send",
"arguments": {
"agent": "hello",
"to": "jordan@example.com",
"subject": "Your order #10473 has shipped",
"body_text": "Your order #10473 is on its way."
}
}{
"id": "msg_01JZX8K3M9Q4P7VN2YB6RTDC0E",
"agent_id": "agt_01JZ9F8H7KQ2M3N4P5R6S7T8U9",
"thread_id": "thrd_01JZX8K3M9Q4P7VN2YB6RTDC11",
"to": ["jordan@example.com"],
"subject": "Your order #10473 has shipped",
"classifier_score": 0.02,
"status": "sent",
"cost_usd": 0.0004,
"tags": [{ "name": "category", "value": "shipping" }],
"test_mode": false,
"created_at": "2026-06-24T17:32:08.421Z"
}201 Message accepted (sent or scheduled).
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.Body Parameters
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.
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.
Who the message goes to: one address, or a list of 1 to 50.
Addresses to copy, up to 50. Every recipient sees them.
Addresses to blind-copy, up to 50. They receive the message but appear in none of its headers.
Subject line, 1 to 998 characters.
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.
HTML body, up to 1,000,000 characters. With body_text as well, the email carries a plain-text part and an HTML part.
Alias of body_html: used when body_html is left out, ignored when both are sent.
Alias of body_text: used when body_text is left out, ignored when both are sent.
The address replies should go to. Left out, replies go to the agent's receiving_address.
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.
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.
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.
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.
Send at this time (ISO 8601) instead of now. A time in the past sends at once. 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.
A future time cannot be combined with attachments.
Up to 10 name-value labels, kept with the message and included in its message.sent event; they are not part of the email.
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.
Headers
Replays the stored response for a repeated key (kept 24 hours), so a retried request never double-sends; the replay carries Idempotent-Replay: true. An error is kept on the key only once the mail has gone out, so a replayed error means it was sent. Once a scheduled send is stored, a failure after that keeps the message's answer on the key instead: the retry answers with the message (201) and raises its message.scheduled if the first request did not. After a failure before either, the same key sends again, as it does after a batch that stored no message and sent no mail.
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.
Response
Message id (msg_…), the {id} in /v1/messages/{id}.
Id of the agent that sent the message (agt_…).
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.
The thread the message belongs to (thrd_…).
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.
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 recipients. Always present on reply responses ([] when none); on the list and the single-message GET, present when there are any.
Bcc recipients. Present only on the list and the single-message GET, when there are any.
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.
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.
The HTML body as sent. Present only on the single-message GET, when there is one.
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.
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).
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.
USD cost of the send. 0 for scheduled or test-mode sends.
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.
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.
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.
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.
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.
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.
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.
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.
When the scheduled send was canceled. Present only on the list and the single-message GET, for a canceled message.
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.
When a recipient's complaint about the message was last reported. Present only on the single-message GET.
Source message id (forward responses only).
True for a message sent with a test key (mk_test_…): nothing was transmitted and nothing was billed.
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.
When the message was created; for a scheduled send, when it was scheduled, not when it went out.
Was this page helpful?