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.sendscope. - A verified domain and a sending key in the
enabledstatus 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.
- TypeScript
- Go
- Python
- cURL
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);
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"data": map[string]any{
"from": map[string]string{"email": "noreply@yourdomain.com", "name": "MyApp"},
"to": map[string]string{"email": "user@example.com", "name": "John"},
"subject": "Your order is confirmed",
"html": "<h1>Thank you!</h1><p>Order number: <b>#12345</b></p>",
},
})
req, _ := http.NewRequest("POST", "https://api.emailmassivo.com/api/v1/public/emails", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_TOKEN"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "order-12345-confirmation")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var body struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&body)
fmt.Println(resp.StatusCode, body.Data.ID)
}
import os
import requests
response = requests.post(
'https://api.emailmassivo.com/api/v1/public/emails',
headers={
'Authorization': f'Bearer {os.environ["API_TOKEN"]}',
'Idempotency-Key': 'order-12345-confirmation',
},
json={
'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>',
},
},
)
print(response.status_code, response.json()['data']['id'])
curl -X POST https://api.emailmassivo.com/api/v1/public/emails \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-12345-confirmation" \
-d '{
"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>"
}
}'
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" }
}
| Field | Meaning |
|---|---|
data.id | Email identifier (UUID). Use it to read the email with GET /api/v1/public/emails/{emailId}; it also arrives as emailId in webhooks. |
data.status | Email status. In a 202 response it is always queued — the email is accepted and waiting to be sent. |
data.createdAt | When the email was accepted. |
data.recipients[] | All recipients of the email, each with its own id and status. |
recipients[].id | Identifier of the delivery to one recipient (UUID). Use it to read delivery details and to match webhook events to a specific recipient. |
recipients[].email, name | Address and name — as they came in the request. |
recipients[].type | to, cc, or bcc. |
recipients[].status | queued — the recipient is accepted for sending; suppressed or bounced — the email isn't sent to this recipient (see When some recipients are rejected). |
recipients[].reason | The 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 sameIdempotency-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.fromSender:
{ "email": "...", "name": "..." },nameis optional, up to 255 characters. The address must be in the sending key's domain, otherwise422 SENDER_DOMAIN_MISMATCH; the domain must be verified.
data.toPrimary recipients — a single
{ email, name? }object or an array of them. At least one address is required.
data.ccCarbon copy — an array of
{ email, name? }objects.
data.bccBlind carbon copy — an array of
{ email, name? }objects.
data.replyToReply address — a
{ email, name? }object or an array, up to 5 addresses. Goes into the email'sReply-Toheader. The domain of these addresses doesn't need to be verified. More in Attachments and headers.
data.subjectEmail subject, up to 255 characters.
variablesare substituted into the subject.
data.previewTextPreheader — the short text a mail client shows next to the subject. Up to 255 characters.
variablesare substituted here too.
data.htmlHTML 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.textPlain text version of the email, up to 4.5 MB.
data.templateIdTemplate id instead of
html/text. Can't be combined withhtml,text, ortemplateAlias. See Templates and aliases.
data.templateAliasTemplate alias instead of its id, for example
order-confirmation. Can't be combined withhtml,text, ortemplateId.
data.variablesVariable values:
{ "name": "John" }. Substituted into the template,html,text,subject, andpreviewTextin place of{{name}}placeholders. Values are strings, numbers,true/false, ornull. See Variables.
data.headersYour 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.attachmentsAttachments:
{ "fileName": "invoice.pdf", "content": "<base64>" }. Up to 20 files and up to 15 MB in total after decoding. See Attachments and headers.
data.keyIdSending key id. If omitted, the default key of the
from.emaildomain 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, andbcc.replyToaddresses don't count toward the limit. - An address can't appear twice across
to,cc, andbcc, case-insensitively. A duplicate returns400 BAD_REQUESTwithreason: 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 theList-UnsubscribeandList-Unsubscribe-Postheaders 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
suppressedand codeRECIPIENT_UNSUBSCRIBEDorRECIPIENT_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 codeRECIPIENT_KNOWN_BOUNCED,RECIPIENT_BLOCKED,RECIPIENT_INVALID, orRECIPIENT_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— alltorecipients are on the key's unsubscribe list;RECIPIENT_BOUNCED— alltorecipients 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 asPOST /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
- Email statuses and events — what happens to the email after
202. - Idempotency and quota —
Idempotency-KeyandX-Quota-Remaining. - Templates and aliases — sending from a template.
- Attachments and headers — files,
replyTo, and your own headers. - Email webhooks —
email.*events. - Migrating to the Email API — field mapping and differences.
- The
POST /api/v1/public/emailsendpoint in the API reference.