Skip to main content

Resend compatibility

Send, batch and read with Resend's SDK: two settings, and the fields, answers and errors it gets back.

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.

To send through Amazon SES in your own AWS account, connect it first: Send through your own Amazon SES.

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/resend

Python, 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>",
});

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.send Sends one email: POST /resend/emails, which runs POST /v1/messages.
  • batch.send Sends a batch of up to 100: POST /resend/emails/batch, which runs POST /v1/messages/batch.
  • emails.get Reads one back: GET /resend/emails/{id}, which runs GET /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

FieldWhat mails.ai does
fromRequired: an address, or Name <address>. It is one of your agents (below).
to, cc, bccOne address or a list. Display names are dropped.
reply_toOne address. A list of more than one is refused.
subjectRequired.
html, textAt least one of them.
headersIn-Reply-To and References, which thread the email. X-Entity-Ref-ID is dropped. Any other header is refused.
attachmentsEach 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.
tagsKept on the email.
scheduled_atAn ISO 8601 date and time with any offset, or a delay such as in 10 min. Other natural-language times are refused.
template, topic_idRefused: mails.ai has no stored templates and no topics.
reactNode.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 from address 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 the manage scope) 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 a reply.received event. Set reply_to to 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.

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

JSON
{
  "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.

JSON
{
  "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_key No key.
  • 401 validation_error A key mails.ai does not accept.
  • 401 restricted_api_key A key without the scope the call needs, such as a sending-only key reading an email.
  • 403 validation_error What 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_found An email this key cannot read, or an endpoint mails.ai does not serve.
  • 405 method_not_allowed A method the path does not take.
  • 409 invalid_idempotent_request An idempotency key used before with a different body.
  • 409 concurrent_idempotent_requests An idempotency key whose first request is still running.
  • 400 invalid_idempotency_key An idempotency key that is not 1 to 256 characters.
  • 422 missing_required_field A field the email needs is missing.
  • 422 invalid_attachment An attachment with neither content nor path.
  • 422 validation_error What 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_exceeded Too many requests, or too many emails this hour.
  • 429 daily_quota_exceeded The day’s emails are used up, or a new workspace’s new recipients for the day.
  • 429 monthly_quota_exceeded The month’s emails are used up.
  • 500, 502, 503 application_error Something 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-To and References.
  • 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?