Skip to main content

Send a transactional email

A transactional email is sent with a single POST /api/v1/public/emails request. Use it for notifications, order confirmations, codes and password reset links — any email triggered by an end user's action.

A 202 Accepted response means the email is accepted for sending. Request errors — the body format, the sending key and domain, the template, attachments, recipients, quota — come in the response to the same request. Whether the email was delivered is visible through reading the email and webhooks.

Before you start​

  • A token with the email.send scope.
  • A verified domain and a sending key in the enabled status in that domain. You add and verify the domain in the dashboard or through the public API: add a domain, verify a domain.

Passing the key in the request is optional: without keyId the email goes through the default key of the sender's domain.

Minimal example​

Required fields: from, to, subject, and content — html or text, or a template (see Templates and aliases). The email fields go inside the data object, as with the other public API endpoints.

The Idempotency-Key header is optional, but always send it: after a network failure you can safely repeat the request with the same key and no second email appears. More in Idempotency and quota.

Before running, replace noreply@yourdomain.com with an address in your key's domain, and user@example.com with your own mailbox. The order-12345-confirmation key is valid for one request body: when you change the body, change the key too.

const response = await fetch('https://api.emailmassivo.com/api/v1/public/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-12345-confirmation',
},
body: JSON.stringify({
data: {
from: { email: 'noreply@yourdomain.com', name: 'MyApp' },
to: { email: 'user@example.com', name: 'John' },
subject: 'Your order is confirmed',
html: '<h1>Thank you!</h1><p>Order number: <b>#12345</b></p>',
},
}),
});
const { data } = await response.json();
console.log(response.status, data.id);

The 202 response​

HTTP/1.1 202 Accepted
X-Quota-Remaining: 9874
{
"ok": true,
"data": {
"id": "0199b2f4-6c1e-7a3d-9f2b-4e8c1d7a5b60",
"status": "queued",
"createdAt": "2026-09-30T10:15:00.000Z",
"recipients": [
{
"id": "3f6c2a9e-8b1d-4c7a-9e2f-5a1b7c3d9e01",
"email": "user@example.com",
"name": "John",
"type": "to",
"status": "queued",
"reason": null
}
]
},
"meta": { "requestId": "01JB2W...", "timestamp": "2026-09-30T10:15:00.120Z" }
}
FieldMeaning
data.idEmail identifier (UUID). Use it to read the email with GET /api/v1/public/emails/{emailId}; it also arrives as emailId in webhooks.
data.statusEmail status. In a 202 response it is always queued — the email is accepted and waiting to be sent.
data.createdAtWhen the email was accepted.
data.recipients[]All recipients of the email, each with its own id and status.
recipients[].idIdentifier of the delivery to one recipient (UUID). Use it to read delivery details and to match webhook events to a specific recipient.
recipients[].email, nameAddress and name — as they came in the request.
recipients[].typeto, cc, or bcc.
recipients[].statusqueued — the recipient is accepted for sending; suppressed or bounced — the email isn't sent to this recipient (see When some recipients are rejected).
recipients[].reasonThe reason for suppressed and bounced, otherwise null. Format in Email statuses and events.

Response headers:

  • X-Quota-Remaining — the email quota left after this send. More in Idempotency and quota.
  • Idempotency-Replayed: true — only on a replayed response to a request with the same Idempotency-Key.

What happens to the email next — sending, delivered, bounced, failed — is visible through reading the email and email.* webhooks.

Request body​

All fields go inside data.

data.from (object, required)

Sender: { "email": "...", "name": "..." }, name is optional, up to 255 characters. The address must be in the sending key's domain, otherwise 422 SENDER_DOMAIN_MISMATCH; the domain must be verified.

data.to (object | array, required)

Primary recipients — a single { email, name? } object or an array of them. At least one address is required.

data.cc (array, optional)

Carbon copy — an array of { email, name? } objects.

data.bcc (array, optional)

Blind carbon copy — an array of { email, name? } objects.

data.replyTo (object | array, optional)

Reply address — a { email, name? } object or an array, up to 5 addresses. Goes into the email's Reply-To header. The domain of these addresses doesn't need to be verified. More in Attachments and headers.

data.subject (string, required)

Email subject, up to 255 characters. variables are substituted into the subject.

data.previewText (string, optional)

Preheader — the short text a mail client shows next to the subject. Up to 255 characters. variables are substituted here too.

data.html (string, optional)

HTML version of the email, up to 4.5 MB. If the email isn't sent from a template, you need html, text, or both.

data.text (string, optional)

Plain text version of the email, up to 4.5 MB.

data.templateId (integer, optional)

Template id instead of html/text. Can't be combined with html, text, or templateAlias. See Templates and aliases.

data.templateAlias (string, optional)

Template alias instead of its id, for example order-confirmation. Can't be combined with html, text, or templateId.

data.variables (object, optional)

Variable values: { "name": "John" }. Substituted into the template, html, text, subject, and previewText in place of {{name}} placeholders. Values are strings, numbers, true/false, or null. See Variables.

data.headers (object, optional)

Your own email headers: { "X-Order-Id": "1234" }, values are strings. System headers are not allowed — 400 HEADER_NOT_ALLOWED. The list is in Attachments and headers.

data.attachments (array, optional)

Attachments: { "fileName": "invoice.pdf", "content": "<base64>" }. Up to 20 files and up to 15 MB in total after decoding. See Attachments and headers.

data.keyId (integer, optional)

Sending key id. If omitted, the default key of the from.email domain is used.

Full example:

{
"data": {
"from": { "email": "noreply@shop.com", "name": "Shop" },
"to": { "email": "client@example.com", "name": "John" },
"cc": [{ "email": "manager@shop.com" }],
"bcc": [{ "email": "archive@shop.com" }],
"replyTo": { "email": "support@shop.com", "name": "Support" },
"subject": "Order confirmation",
"previewText": "Order #1234 received",
"html": "<h1>Thank you, {{name}}!</h1>",
"text": "Thank you, {{name}}!",
"variables": { "name": "John" },
"headers": { "X-Order-Id": "1234" },
"attachments": [
{ "fileName": "invoice.pdf", "content": "JVBERi0xLjQK..." }
],
"keyId": 42
}
}

Limits​

  • No more than 50 recipients across to, cc, and bcc. replyTo addresses don't count toward the limit.
  • An address can't appear twice across to, cc, and bcc, case-insensitively. A duplicate returns 400 BAD_REQUEST with reason: DUPLICATE_RECIPIENT.
  • Request body — no more than 32 MB, otherwise 413 PAYLOAD_TOO_LARGE.
  • Attachments — no more than 20, up to 15 MB in total after base64 decoding.

Several addresses in to are one email​

"to": [a, b, c] is one email: recipients see each other, the subject and body are shared, and there is one Idempotency-Key per email. If each recipient needs a separate email, send a separate request for each.

Variables​

A placeholder is a variable name in double curly braces: {{name}}; spaces inside the braces are allowed ({{ name }}). A name consists of letters, digits, _, and -. There are no other constructs — no conditions, no loops.

  • Values are substituted as is, without HTML escaping. If a value comes from a user (a name, an address, a comment), escape it on your side before sending.
  • A placeholder without a value stays in the email as is.
  • {{unsubscribe_url}} is the link to unsubscribe from this sending key's emails; EmailMassivo fills it in. EmailMassivo also sets the List-Unsubscribe and List-Unsubscribe-Post headers itself.

When some recipients are rejected​

Before an email is accepted, its addresses are checked against two lists:

  • The key's unsubscribe list. The recipient unsubscribed from this sending key's emails or complained about them. Such a recipient comes back with status suppressed and code RECIPIENT_UNSUBSCRIBED or RECIPIENT_COMPLAINED. To send to such an address through this key again, contact EmailMassivo support.
  • The EmailMassivo list of undeliverable addresses. The address is already known not to exist or is blocked, or the address's domain doesn't accept mail. Such a recipient comes back with status bounced, bounceType: permanent, and code RECIPIENT_KNOWN_BOUNCED, RECIPIENT_BLOCKED, RECIPIENT_INVALID, or RECIPIENT_DOMAIN_INVALID.

If at least one to recipient remains who will be sent the email, the email is accepted (202). Rejected recipients appear in recipients[] with their status and reason; the email isn't sent to them, no quota is charged for them, and email.suppressed and email.bounced webhooks arrive for them.

The To and Cc headers stay as in the request for every recipient, rejected addresses included: it is one email, only its copy to those addresses isn't sent.

{
"id": "b81e4d27-0c5a-4f93-a6d8-2e9f1c4b7a35",
"email": "archive@shop.com",
"name": null,
"type": "bcc",
"status": "suppressed",
"reason": {
"code": "RECIPIENT_UNSUBSCRIBED",
"message": "Recipient unsubscribed from this sender."
}
}

If no such recipient remains in to, the email isn't sent to anyone, including cc and bcc, and no quota is charged. The response is 422:

  • RECIPIENT_SUPPRESSED — all to recipients are on the key's unsubscribe list;
  • RECIPIENT_BOUNCED — all to recipients are rejected, and at least one of them by the list of undeliverable addresses.

error.details[] lists every rejected address of the email. Repeating such a request is pointless — the response will be the same.

{
"ok": false,
"error": {
"code": "RECIPIENT_BOUNCED",
"message": "None of the primary recipients can receive email.",
"details": [
{ "field": "to[0].email", "reason": "RECIPIENT_KNOWN_BOUNCED", "value": "a@example.com" }
]
},
"meta": { "requestId": "01JB2W...", "timestamp": "2026-09-30T10:15:00.120Z" }
}

Which sending key is used​

With keyId the email goes through that key; without it — through the default key of the from.email domain. How to find and change the default key, and which errors can occur — Sending keys → Default key.

Rate limit​

POST /api/v1/public/emails has two limits:

  • 600 requests per minute per API key (for MCP clients — per OAuth grant). All tokens of one API key share one budget; it isn't shared with the other public API endpoints. The current budget state comes in the X-RateLimit-* headers.
  • 20 emails per second per sender domain. One request is one email, however many recipients it has.

When either is exceeded — 429 RATE_LIMIT_EXCEEDED and a Retry-After header with the number of seconds to wait. For the domain limit, details has { "field": "from.email", "reason": "DOMAIN_RATE_LIMIT" }. 429 isn't stored for the Idempotency-Key: after the pause, retry the request with the same key. For traffic spikes, keep a queue on your side with even pacing.

Key throttling for traffic quality (isThrottled) doesn't affect acceptance: emails are accepted but delivered with a delay — see Traffic quality control.

Sending through MCP​

If you've connected EmailMassivo to an AI assistant over MCP, sending and reading emails are available as tools:

  • public_emails_send — send an email (same fields as POST /api/v1/public/emails);
  • public_emails_get — read an email;
  • public_emails_get_recipient — delivery details for one recipient;
  • public_emails_get_recipient_events — all events of a recipient.

Sending is irreversible, so before calling public_emails_send the assistant should confirm the recipients, sender, and subject with you. There is no idempotency over MCP: calling public_emails_send again sends another email. The assistant can't see the remaining quota — the X-Quota-Remaining header only exists in the HTTP response.

Handling errors​

Errors come in the standard public API format: { "ok": false, "error": { "code", "message", "details" }, "meta" }. The full list of codes, their causes, and what to do is in Send error codes.

class UndeliverableRecipientsError extends Error {
constructor(public details: unknown) { super('undeliverable recipients'); }
}
class InsufficientQuotaError extends Error {
constructor(public remaining: number) { super('insufficient quota'); }
}
class RetryableError extends Error {
constructor(public code: string, public retryAfterSec: number) { super(code); }
}

async function sendEmail(data: object, idempotencyKey: string) {
const response = await fetch('https://api.emailmassivo.com/api/v1/public/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ data }),
});
const body = await response.json();

if (response.status === 202) {
return body.data; // email id and recipient statuses
}

const { code, details } = body.error;
if (code === 'RECIPIENT_BOUNCED' || code === 'RECIPIENT_SUPPRESSED') {
throw new UndeliverableRecipientsError(details); // don't retry
}
if (code === 'PAYMENT_REQUIRED') {
throw new InsufficientQuotaError(Number(response.headers.get('X-Quota-Remaining')));
}
if (response.status === 429 || code === 'REQUEST_ALREADY_IN_PROGRESS' || response.status >= 500) {
// retry later with the same idempotencyKey
throw new RetryableError(code, Number(response.headers.get('Retry-After') ?? 60));
}
throw new Error(`${response.status} ${code}: ${body.error.message}`);
}

See also​