Emails API
Send transactional email from your own verified domain, without an inbox. The request and response follow Resend's API, so the Resend SDK works too. Schedule an email for later or send up to 100 in one request.
To send from an inbox and reply in threads, see Sending. The Emails API is for mail your app sends from an address such as noreply@acme.com: receipts, sign in links, notifications. It shares the same suppressions, plan limits, automatic pause, test addresses and events.
Send an email
POST/v1/emails
Sends one email and answers 200 with its id. A full access key or a sending key of the project works; a sending key limited to one domain only sends from that domain.
| Field | Type | Description |
|---|---|---|
| from | string | Required. noreply@acme.com or Acme <noreply@acme.com>. The domain must be a verified domain of the key's project, otherwise 403 sender_not_verified. Any address on that domain works; GET /v1/senders lists them. |
| to, cc, bcc | string | string[] | One address or a list, with or without a display name. At least one recipient, at most 50 across all three. |
| reply_to | string | string[] | Sets the Reply-To header. |
| subject | string | One line, at most 998 characters. |
| html, text | string | The body. At least one of them is required. |
| headers | object | Extra headers such as {"X-Entity-Ref-ID": "123"}. Headers Mailbay sets itself are refused. |
| attachments | Attachment[] | filename, content as base64, an optional content_type (derived from the filename when missing) and an optional content_id for an inline image referenced as cid:<content_id>. |
| tags | Tag[] | Up to 50 {"name", "value"} pairs, each 1 to 256 ASCII letters, numbers, underscores or dashes. They come back in the email and its events. |
| scheduled_at | string | Send later. See Scheduled send. |
curl https://api.mailbay.dev/v1/emails \
-H "Authorization: Bearer $MAILBAY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.com>",
"to": ["delivered@test.mailbay.dev"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"tags": [{ "name": "category", "value": "receipt" }]
}'
# {"id": "eml_01jc2m5x7t3v8q0w4r6y9z1a2b"}import { Mailbay } from "mailbay";
const mailbay = new Mailbay(); // reads MAILBAY_API_KEY
const { id } = await mailbay.emails.send({
from: "Acme <noreply@acme.com>",
to: ["delivered@test.mailbay.dev"],
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
tags: [{ name: "category", value: "receipt" }],
});Addresses are lowercased and deduplicated, and Bcc never appears in the headers. Mailbay signs the email with your domain's DKIM key. The whole email, attachments included, may be at most 25 MB after encoding.
Idempotency
Send an Idempotency-Key header (1 to 255 printable ASCII characters, a UUID works) and a retry after a timeout never sends twice. Keys are remembered for 24 hours. The same key with the same body returns the first email's id without sending again; the same key with a different body is 409 idempotency_conflict. If the first request failed, the key is released and a retry runs again. The Node SDK adds a key to every send for you.
Retrieve an email
GET/v1/emails/{id}
Returns the email with its bodies, attachments (metadata only) and the status of each recipient. last_event is Resend's name for the latest status, and response is the receiving server's answer, such as 550 5.1.1 no such user.
{
"object": "email",
"id": "eml_01jc2m5x7t3v8q0w4r6y9z1a2b",
"project_id": "prj_01jbz8r0c4m2",
"domain_id": "dom_01jbz8t6v1k9",
"from": "noreply@acme.com",
"from_name": "Acme",
"to": ["delivered@test.mailbay.dev"],
"cc": [],
"bcc": [],
"reply_to": [],
"subject": "Your receipt",
"status": "delivered",
"last_event": "delivered",
"scheduled_at": null,
"tags": [{ "name": "category", "value": "receipt" }],
"size_bytes": 1843,
"created_at": "2026-10-14T09:41:07.512Z",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"headers": {},
"attachments": [],
"recipients": [
{
"address": "delivered@test.mailbay.dev",
"kind": "to",
"status": "delivered",
"response": "250 2.0.0 OK",
"updated_at": "2026-10-14T09:41:09.120Z"
}
]
}List emails
GET/v1/emails?limit=&cursor=
The project's sent emails, newest first, without bodies. limit is 1 to 100 (default 50). Pass next_cursor from the previous page as cursor; it is null on the last page. A sending key limited to one domain only sees that domain's emails.
curl "https://api.mailbay.dev/v1/emails?limit=20" \
-H "Authorization: Bearer $MAILBAY_API_KEY"
# {"data": [ ...emails ], "next_cursor": "eml_01jc2k..."}Statuses
status and last_event move through these values. Each change emits an event to your webhooks.
| Status | Event | Meaning |
|---|---|---|
| scheduled | message.scheduled | Waiting for its scheduled_at. |
| canceled | message.canceled | Canceled while it was scheduled. It will never be sent. |
| queued | message.queued | Stored and handed to the mail server. |
| sent | The mail server accepted it for every recipient. | |
| delivered | message.delivered | Every recipient's server accepted it. |
| deferred | message.deferred | A temporary failure; the mail server keeps retrying. |
| bounced | message.bounced | A recipient's server refused it permanently. |
| failed | message.failed | Not sent: the mail server did not accept it or gave up, or a check failed at the scheduled time. |
| complained | message.complained | A recipient reported it as spam. |
With several recipients the status summarizes them, as for inbox messages.
Scheduled send
Add scheduled_at to send later. It takes either form:
| Form | Example | Notes |
|---|---|---|
| ISO 8601 | 2026-10-20T09:00:00Z | With a UTC offset, such as Z or +05:30. A time without an offset is refused. |
| Relative | in 2 hours | in <n> <unit>, the unit being sec, second, min, minute, hr, hour, day or week, singular or plural. |
Other natural language, such as tomorrow at 9am, is refused with 400 invalid_scheduled_at. The time may be at most 30 days ahead. A time now or up to 5 minutes in the past sends at once (clocks drift); anything older is refused.
curl https://api.mailbay.dev/v1/emails \
-H "Authorization: Bearer $MAILBAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <noreply@acme.com>",
"to": "bob@example.com",
"subject": "Your trial ends tomorrow",
"text": "Hi Bob, your trial ends tomorrow.",
"scheduled_at": "2026-10-20T09:00:00+05:30"
}'
# {"id": "eml_01jc2m9k4p7r2t5v8x0z3b6d9f"}const { id } = await mailbay.emails.send({
from: "Acme <noreply@acme.com>",
to: "bob@example.com",
subject: "Your trial ends tomorrow",
text: "Hi Bob, your trial ends tomorrow.",
scheduled_at: "in 2 hours",
});
// Move it, or cancel it, while it is still scheduled.
await mailbay.emails.update(id, { scheduled_at: "2026-10-20T09:00:00Z" });
await mailbay.emails.cancel(id);The response is the same {"id"} as an immediate send. The email is stored with status scheduled and GET /v1/emails/{id} shows its scheduled_at. You get a message.scheduled event with email_id, message_id, scheduled_at, from, to, cc, subject and tags; message.queued comes when it is sent.
Everything is checked when you schedule, so a bad email fails at once: the sender, the body, suppressions and the sending pause. Plan limits are not counted then; the recipients count on the day the email is sent.
At the scheduled time Mailbay checks the sending pause, that the domain is still verified, suppressions and plan limits again. If they pass, the email goes out exactly like an immediate send: queued, then sent, with message.queued and the delivery events. If one fails, the email becomes failed, every recipient is failed with the reason in its response, and you get message.failed with a reason. Scheduled emails survive restarts. Mail sent over SMTP cannot be scheduled.
Reschedule
PATCH/v1/emails/{id}
Moves a scheduled email to a new scheduled_at, with the same rules as above. It only works while the email is scheduled, otherwise 409 email_not_scheduled.
curl -X PATCH https://api.mailbay.dev/v1/emails/eml_01jc2m9k4p7r2t5v8x0z3b6d9f \
-H "Authorization: Bearer $MAILBAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scheduled_at": "2026-10-21T09:00:00Z"}'
# {"object": "email", "id": "eml_01jc2m9k4p7r2t5v8x0z3b6d9f"}Cancel
POST/v1/emails/{id}/cancel
Cancels a scheduled email. It takes no body. The email and its recipients become canceled, and you get a message.canceled event with email_id, message_id and scheduled_at. It only works while the email is scheduled, otherwise 409 email_not_scheduled. A canceled email cannot be rescheduled; send a new one.
curl -X POST https://api.mailbay.dev/v1/emails/eml_01jc2m9k4p7r2t5v8x0z3b6d9f/cancel \
-H "Authorization: Bearer $MAILBAY_API_KEY"
# {"object": "email", "id": "eml_01jc2m9k4p7r2t5v8x0z3b6d9f"}Reschedule and cancel need the same access as sending, on the email's domain.
Batch send
POST/v1/emails/batch
Sends up to 100 emails in one request. The body is a JSON array; each item is exactly a POST /v1/emails body, scheduled_at and attachments included. The response lists the ids in request order.
curl https://api.mailbay.dev/v1/emails/batch \
-H "Authorization: Bearer $MAILBAY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '[
{
"from": "Acme <noreply@acme.com>",
"to": "alice@example.com",
"subject": "Your weekly report",
"html": "<p>Hi Alice</p>"
},
{
"from": "Acme <noreply@acme.com>",
"to": "bob@example.com",
"subject": "Your weekly report",
"html": "<p>Hi Bob</p>",
"scheduled_at": "in 1 hour"
}
]'
# {"data": [{"id": "eml_01jc2mb1..."}, {"id": "eml_01jc2mb2..."}]}const { data } = await mailbay.emails.batch([
{ from: "Acme <noreply@acme.com>", to: "alice@example.com", subject: "Your weekly report", html: "<p>Hi Alice</p>" },
{ from: "Acme <noreply@acme.com>", to: "bob@example.com", subject: "Your weekly report", html: "<p>Hi Bob</p>" },
]);
// data: [{ id: "eml_..." }, { id: "eml_..." }], in request orderAn empty body, or one that is not an array, is 400 invalid_batch; more than 100 items is 400 batch_too_large. Each email goes through the same path as a single send: sender rule, suppressions, sending pause, DKIM, events and usage.
Plan limits are checked once, for the sum of the recipients of every email sent now (scheduled items count when they are sent). If the batch would go over the limit, the whole batch is refused with 402 and nothing is sent.
An Idempotency-Key covers the whole batch, with the same 24 hour rules: a repeat returns the same ids, and a different batch with the same key is 409 idempotency_conflict.
Batch validation
The x-batch-validation header picks what happens when some items are invalid.
| Mode | Header | Invalid items |
|---|---|---|
| strict | x-batch-validation: strict | The default. Nothing is stored or sent; the request fails with 400 invalid_batch. |
| permissive | x-batch-validation: permissive | The valid items are sent and the invalid ones are reported next to them. |
Any other value is 400 invalid_batch_validation.
In strict mode error.message names the first bad item and error.errors lists every bad item with its 0 based index, code, message and fix:
{
"error": {
"code": "invalid_batch",
"message": "emails[1]: \"bob@\" is not a valid email address; nothing in this batch was sent",
"fix": "Fix the emails listed in error.errors (index is the position in the array) and send the batch again, or send it with the header x-batch-validation: permissive to send the valid ones",
"errors": [
{
"index": 1,
"code": "invalid_address",
"message": "\"bob@\" is not a valid email address",
"fix": "Use a plain address such as user@example.com"
}
]
}
}In permissive mode the response is 200 with the ids of the emails sent and the errors of the rest:
{
"data": [{ "id": "eml_01jc2mb1..." }, { "id": "eml_01jc2mb3..." }],
"errors": [
{
"index": 1,
"code": "invalid_address",
"message": "\"bob@\" is not a valid email address",
"fix": "Use a plain address such as user@example.com"
}
]
}Unsupported fields
These Resend fields are refused with 400 unsupported_field, never silently dropped, and the fix says what to send instead.
| Field | Status | Instead |
|---|---|---|
| template | 400 | Render the template yourself and send html and text. |
| react | 400 | Render the component to html first, for example with @react-email/render, and send html. |
| topic_id | 400 | Remove it, and filter recipients before sending. |
| attachments[].path | 400 | Send the file itself as base64 in attachments[].content. Mailbay does not fetch URLs. |
Errors
| Code | Status | Meaning |
|---|---|---|
| sender_not_verified | 403 | The from domain is not a domain of this project, or it is not verified yet. Add it with mailbay domain add and verify it. |
| invalid_scheduled_at | 400 | scheduled_at is not ISO 8601 with an offset or in <n> <unit>, is more than 30 days ahead, or is more than 5 minutes in the past. The fix shows an ISO 8601 example. |
| email_not_scheduled | 409 | Reschedule and cancel only work while the email is scheduled. It was already sent, failed or canceled. |
| invalid_batch | 400 | The batch body is empty or not a JSON array, or, in strict mode, an item is invalid. error.errors lists every bad item. |
| batch_too_large | 400 | More than 100 emails in one batch. Split it into several requests. |
| unsupported_field | 400 | A Resend field Mailbay does not support yet. See Unsupported fields. |
| idempotency_conflict | 409 | The Idempotency-Key was already used with a different request body. |
The other sending errors, such as recipient_suppressed, sending_paused and the plan limits, are listed in Sending.
Use the Resend SDK
/v1/emails accepts Resend's request body, and the official resend Node package reads its base URL from RESEND_BASE_URL. If your code already uses it, switching is two variables:
RESEND_BASE_URL=https://api.mailbay.dev/v1
RESEND_API_KEY=mb_live_xxxxxxxxx # a Mailbay sending keyimport { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: "Acme <noreply@acme.com>",
to: ["delivered@test.mailbay.dev"],
subject: "Hello from the Resend SDK",
html: "<strong>It works</strong>",
});resend 6.32.1: emails.send and emails.get work. Batch, reschedule and cancel use the same paths and bodies as Resend's API. See Mailbay vs Resend for the full comparison.