Resend compatibility
mails.ai answers the calls Resend’s official SDKs make to send an email, send a batch and read an email back. Point the SDK at https://api.mails.ai/resend with a mails.ai API key, and the app you have sends through mails.ai with no change to its code. Every email still goes through each check a mails.ai send gets.
Point the SDK at mails.ai
Set two values where your app reads its environment: your mails.ai key (mk_live_…, or mk_test_… to try it without sending) as RESEND_API_KEY, and the base URL. Python reads it as RESEND_API_URL, the others as RESEND_BASE_URL. Go and Ruby add the path to it without a slash of their own, so their value ends in one:
RESEND_API_KEY=mk_live_…
RESEND_BASE_URL=https://api.mails.ai/resendRESEND_API_KEY=mk_live_…
RESEND_API_URL=https://api.mails.ai/resendRESEND_API_KEY=mk_live_…
RESEND_BASE_URL=https://api.mails.ai/resend/RESEND_API_KEY=mk_live_…
RESEND_BASE_URL=https://api.mails.ai/resend/RESEND_API_KEY=mk_live_…
RESEND_BASE_URL=https://api.mails.ai/resendPython, Ruby and Go read the base URL when the SDK loads, and Node.js and PHP when you create the client, so set it before your app starts. In Node.js you can also pass it in code: new Resend(key, { baseUrl: "https://api.mails.ai/resend" }).
import { Resend } from "resend";
// In the environment:
// RESEND_API_KEY=mk_live_…
// RESEND_BASE_URL=https://api.mails.ai/resend
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: "Acme <hello@acme.com>",
to: ["customer@example.com"],
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
});import os
# reads RESEND_API_URL when imported
import resend
resend.api_key = os.environ["RESEND_API_KEY"]
email = resend.Emails.send({
"from": "Acme <hello@acme.com>",
"to": ["customer@example.com"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
})curl https://api.mails.ai/resend/emails \
-H "Authorization: Bearer $MAILS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": ["customer@example.com"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>"
}'A send answers 200 with the email’s id, as Resend does. With a test key, it also carries classifier_warning when a live key’s send would have been refused.
The calls it answers
emails.sendSends one email:POST /resend/emails, which runsPOST /v1/messages.batch.sendSends a batch of up to 100:POST /resend/emails/batch, which runsPOST /v1/messages/batch.emails.getReads one back:GET /resend/emails/{id}, which runsGET /v1/messages/{id}.
Each runs its mails.ai route with every guard that route has: the key’s scopes and rate limit, idempotency, the agent’s limits, suppression, the cold-email check and your plan. Every other path under /resend answers Resend’s 404 not_found, so emails.list, emails.update, emails.cancel and the domains, contacts and broadcasts APIs get an error the SDK knows.
Fields
| Field | What mails.ai does |
|---|---|
from | Required: an address, or Name <address>. It is one of your agents (below). |
to, cc, bcc | One address or a list. Display names are dropped. |
reply_to | One address. A list of more than one is refused. |
subject | Required. |
html, text | At least one of them. |
headers | In-Reply-To and References, which thread the email. X-Entity-Ref-ID is dropped. Any other header is refused. |
attachments | Each by its content (a base64 string, or a Buffer from Node.js) or by an https path mails.ai fetches, with filename, content_type and content_id. None in a batch, as on Resend. |
tags | Kept on the email. |
scheduled_at | An ISO 8601 date and time with any offset, or a delay such as in 10 min. Other natural-language times are refused. |
template, topic_id | Refused: mails.ai has no stored templates and no topics. |
react | Node.js only: the SDK renders it to html before it sends, so it works. |
An idempotency key (the SDK’s idempotencyKey option, sent as Idempotency-Key) works as on Resend: the same key with the same email sends once.
Senders and replies
- Each
fromaddress is an agent in your workspace. The first email from a new address creates its agent when the key may create agents (a full-access key, which holds themanagescope) and your plan has room for one more; otherwise the send is refused and nothing is created. - The domain must be verified, at mails.ai or in the Amazon SES account you connected. An email from a domain verified in neither is refused with
403 validation_error, as Resend refuses it. An address on your own mails.ai subdomain sends as any agent does. - Replies come back to mails.ai unless you set
reply_to: the email’s Reply-To is the agent’s receiving address, and a reply arrives as areply.receivedevent. Setreply_toto keep replies going to a mailbox of your own.
Batches
batch.send takes 1 to 100 emails, with no attachments. Strict validation, the default, sends nothing when any email is invalid, and the answer names it by its index, counting from 0 (Email 0 in the batch: …). Permissive validation sends the valid ones and lists the others in errors, by the same index.
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.batch.send(
[
{
from: "Acme <hello@acme.com>",
to: ["ana@example.com"],
subject: "Your receipt",
html: "<p>Thanks, Ana.</p>",
},
{
from: "Acme <hello@acme.com>",
to: ["ben@example.com"],
subject: "Your receipt",
html: "<p>Thanks, Ben.</p>",
},
],
{ batchValidation: "permissive" },
);One difference from Resend: a few checks run only as the batch is sent, such as the cold-email check, suppression and your plan’s limits. When some emails of a strict batch went out before one of those refused the rest, the answer lists the sent ones in data and the refused ones in errors, rather than an error that would have your app send them all again.
Reading an email
emails.get answers in Resend’s shape, with the From line, Reply-To and Message-ID the email went out with.
{
"object": "email",
"id": "msg_01K8…",
"message_id": "<…>",
"to": ["customer@example.com"],
"from": "Acme <hello@acme.com>",
"created_at": "2026-10-21T09:30:00.000Z",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"text": null,
"cc": null,
"bcc": null,
"reply_to": ["hello.acme_com@in.mails.ai"],
"last_event": "delivered",
"scheduled_at": null
}last_event is queued, scheduled, sent, delivered, delivery_delayed, bounced, complained, failed or canceled. It is never opened or clicked: mails.ai does not track opens or clicks.
Errors
An error comes back in Resend’s shape, so the SDK hands your app the error it expects, and the mails.ai error that caused it rides along in mails_error: its code, message, param and request_id, and resets_at or upgrade_url when it has them.
{
"statusCode": 403,
"name": "validation_error",
"message": "The acme.com domain is not verified. Please, add and verify your domain: at mails.ai with POST /v1/domains, or in the Amazon SES account connected with POST /v1/ses-connection.",
"mails_error": {
"type": "invalid_request_error",
"code": "domain_not_verified",
"message": "The acme.com domain is not verified. Please, add and verify your domain: at mails.ai with POST /v1/domains, or in the Amazon SES account connected with POST /v1/ses-connection.",
"param": "from",
"request_id": "req_…"
}
}Resend’s status and name for each kind of refusal:
401 missing_api_keyNo key.401 validation_errorA key mails.ai does not accept.401 restricted_api_keyA key without the scope the call needs, such as a sending-only key reading an email.403 validation_errorWhat the account may not do: a from domain that is not verified, a sender it may not use, a feature or an agent your plan does not include, your Amazon SES not ready to send.404 not_foundAn email this key cannot read, or an endpoint mails.ai does not serve.405 method_not_allowedA method the path does not take.409 invalid_idempotent_requestAn idempotency key used before with a different body.409 concurrent_idempotent_requestsAn idempotency key whose first request is still running.400 invalid_idempotency_keyAn idempotency key that is not 1 to 256 characters.422 missing_required_fieldA field the email needs is missing.422 invalid_attachmentAn attachment with neither content nor path.422 validation_errorWhat is wrong with this email: an invalid field, a field mails.ai cannot send, a suppressed recipient, a cold email the API refuses.429 rate_limit_exceededToo many requests, or too many emails this hour.429 daily_quota_exceededThe day’s emails are used up, or a new workspace’s new recipients for the day.429 monthly_quota_exceededThe month’s emails are used up.500, 502, 503 application_errorSomething failed on our side, or upstream.
The rate-limit headers, Retry-After and X-Request-Id come back as mails.ai sends them.
What does not carry over
- Stored templates and topics.
- Custom headers other than
In-Reply-ToandReferences. - More than one Reply-To address.
- Listing, updating and cancelling emails.
- Resend’s domains, contacts, broadcasts and other APIs: use the mails.ai API for what it has of them.
- Open and click tracking.
Was this page helpful?