Migrating to the Email API
The new public API email endpoints replace the legacy ones:
| Legacy endpoint | New |
|---|---|
POST /api/v1/external-mails/send, /send-by-template | POST /api/v1/public/emails |
GET /api/v1/public/external-mails | Sending history: GET /api/v1/public/emails/recipients and GET /api/v1/public/emails |
/api/v1/public/external-mails/keys | /api/v1/public/sending-keys |
external_mail.* scopes | email.* and sending_keys.* — mapping |
The legacy endpoints keep working; what changed in their behaviour is in Changes in the legacy endpoints. The legacy endpoints and scope names will be turned off — we'll announce the shutdown date in advance. This article lists the field mapping and the differences to account for.
Before you start
- A token with the
email.sendscope. A token with the legacyexternal_mail.sendscope works too — see Scopes. The new endpoint only acceptsAuthorization: Bearer; it doesn't accept theX-Api-Keyheader. - To read emails and the history — the
email.readscope; for keys —sending_keys.readandsending_keys.write.
Scopes
The legacy and the new name are the same permission:
| Legacy scope | New |
|---|---|
external_mail.send | email.send |
external_mail.read | email.read and sending_keys.read |
external_mail.write | sending_keys.write |
- A token with the legacy scopes works on both the legacy and the new endpoints — no need to reissue or change it. A new token works on the legacy endpoints too.
GET /api/v1/public/mereturns both names of each permission. If your integration checks forexternal_mail.*there, it will keep working.- In the dashboard, a token shows only the new scopes — for a legacy token these are its effective permissions.
- MCP clients (OAuth) see the new tools right away, without re-authorizing. Request the new names: in a request that mixes new and legacy names (
email.send external_mail.read), the legacy ones aren't granted.
What you gain
- Errors come in the response to the request. Format, key and domain, template, attachments, recipients, quota are checked right away;
202means the email is accepted for sending. - The standard public API format: responses in
{ ok, data, meta }, errors with codes and per-fielddetails[]. - One email, all recipients:
cc/bccare arrays, every recipient gets its ownidand status in the response, no matching by position. - Reading statuses and events by the email
id, andemail.*webhooks with a reason andbounceType. - Default key:
keyIdcan be omitted. - Template and ready-made HTML in one endpoint, and the template can be referenced by alias.
Request
| Legacy endpoint | POST /api/v1/public/emails |
|---|---|
POST /api/v1/external-mails/send/{key_id} or X-Api-Key | POST /api/v1/public/emails, Bearer token; the key is the keyId field or the default key |
/send-by-template | The same endpoint with templateId or templateAlias |
Root mail | Root data |
idempotencyKey in the body or Idempotency-Key | Only the Idempotency-Key header |
mail.to — an object | to — an object or an array |
mail.cc, mail.bcc — a comma-separated string | cc, bcc — arrays of { email, name? } |
mail.previewTitle | previewText |
mail.idTemplateMailUser | templateId (or templateAlias) |
mail.params | variables — substituted into both templates and ready-made html/text, subject, previewText |
mail.headers["Reply-To"] | The replyTo field (up to 5 addresses) |
mail.headers with any headers | headers without system headers — otherwise 400 HEADER_NOT_ALLOWED |
mail.attachments: [{ "name": "base64" }] | attachments: [{ fileName, content }] |
Before:
{
"mail": {
"to": { "email": "client@example.com", "name": "John" },
"from": { "email": "noreply@shop.com", "name": "Shop" },
"subject": "Order confirmation",
"previewTitle": "Order #1234 received",
"html": "<h1>Thank you!</h1>",
"cc": "manager@shop.com, boss@shop.com",
"headers": { "Reply-To": "support@shop.com", "X-Order-Id": "1234" },
"attachments": [{ "invoice.pdf": "JVBERi0xLjQK..." }]
},
"idempotencyKey": "order-1234-confirmation"
}
After (with the Idempotency-Key: order-1234-confirmation header):
{
"data": {
"from": { "email": "noreply@shop.com", "name": "Shop" },
"to": { "email": "client@example.com", "name": "John" },
"cc": [{ "email": "manager@shop.com" }, { "email": "boss@shop.com" }],
"replyTo": { "email": "support@shop.com" },
"subject": "Order confirmation",
"previewText": "Order #1234 received",
"html": "<h1>Thank you!</h1>",
"headers": { "X-Order-Id": "1234" },
"attachments": [{ "fileName": "invoice.pdf", "content": "JVBERi0xLjQK..." }],
"keyId": 42
}
}
Response
| Legacy endpoint | POST /api/v1/public/emails |
|---|---|
{ uuid, additionalRecipients, warning?, emailId } without an envelope | 202 and { ok, data: { id, status, createdAt, recipients[] }, meta } |
uuid — the delivery of the first successful recipient | data.recipients[].id — for every recipient; data.id — the email as a whole |
additionalRecipients[] — {uuid} or {error} in their own order | recipients[] — all recipients in to, cc, bcc order, each with email, type, status, reason |
warning: "Idempotency key not provided." | None. Without Idempotency-Key a retry creates a new email |
| — | The X-Quota-Remaining header — the remaining quota |
The values stay the same: the legacy response's uuid and additionalRecipients[].uuid are the same ids as the new recipients[].id and as taskId in the legacy webhooks. The legacy response's emailId is the new data.id: use it to read an email sent through a legacy endpoint with GET /api/v1/public/emails/{emailId}.
Errors
The legacy endpoints respond with { "message", "statusCode" }; the new one uses the standard public API format with error.code. The main mappings:
| Legacy error | New error |
|---|---|
400 with the violations in message | 400 BAD_REQUEST with the violations in details[] |
402 Is not enough resource on user balance | 402 PAYMENT_REQUIRED and X-Quota-Remaining |
403 API key is not active | 422 SENDING_KEY_NOT_ACTIVE |
404 KEY_NOT_FOUND, 403 KEY_ACCESS_DENIED | 404 SENDING_KEY_NOT_FOUND |
404 User Domain not found | 404 SENDING_KEY_DOMAIN_NOT_FOUND, 422 SENDER_DOMAIN_MISMATCH, or 422 SENDING_KEY_DOMAIN_NOT_VERIFIED |
422 Email receiver doesn't exist | A bounced recipient with RECIPIENT_KNOWN_BOUNCED in 202, or 422 RECIPIENT_BOUNCED if all to are rejected |
422 Email receiver unsubscribed…, …complained… | A suppressed recipient in 202, or 422 RECIPIENT_SUPPRESSED if all to are rejected |
503 | 503 SERVICE_UNAVAILABLE — the email isn't sent; retry with the same Idempotency-Key |
The full list is in Send error codes.
Behaviour differences
Differences that may require changes to your integration.
to rejected — copies aren't sentIf the email can't be sent to any to recipient, the new endpoint responds 422 and doesn't send cc and bcc. The legacy endpoints sent the copies in this case.
- Partial rejection. Unsubscribed and undeliverable recipients don't turn into a request error: the email is accepted, and they appear in
recipients[]with statussuppressedorbounced. Retrying422 RECIPIENT_BOUNCEDand422 RECIPIENT_SUPPRESSEDis pointless. - No quota for rejected recipients — for recipients with status
suppressedandbouncedin the202response. - Duplicate addresses across
to,cc,bccreturn400 BAD_REQUESTwithreason: DUPLICATE_RECIPIENT. - Up to 50 recipients across
to,cc, andbcc. - No automatic idempotency key. Without a key, the legacy endpoints computed one from the sender, recipient, subject, and HTML. The new one has no automatic key: pass
Idempotency-Key, otherwise every retry is a new email. - Retrying
402. A402response isn't stored for the idempotency key: after changing your plan or when the next billing period starts, retry with the same key. - Limits — 600 requests per minute per API key, separate from the other public API endpoints, and 20 emails per second per sender domain — see Rate limit.
Sending history
GET /api/v1/public/external-mails is replaced by two lists — the sending history:
- Deliveries —
GET /api/v1/public/emails/recipients: one row per recipient, as in the legacy list, for the entire history. - Emails —
GET /api/v1/public/emails: one row per email with all its recipients, only for emails accepted since ДД.ММ.ГГГГ.
| Legacy list | Delivery list |
|---|---|
page, limit, and a total | limit (up to 100) and cursor from the previous response's nextCursor; no total |
toEmail, fromEmail | to, from |
keyId, status, dateFrom, dateTo | The same names; status — delivery statuses |
id — a number | id — a string, the same delivery id as recipients[].id and taskId in the legacy webhooks; emailId — the email id |
recipient | email, plus the recipient's name and type |
Differences:
- Statuses — the public delivery statuses:
queued,sending,delivered,suppressed,bounced,failed. Deliveries the legacy list hid (rejected by the unsubscribe list, and expired ones) are visible assuppressedandfailed. - Dates — an ISO 8601 date or date-time in UTC; a date without a time means the whole UTC day. The legacy list truncated dates to the day.
- Deliveries from before ДД.ММ.ГГГГ — have
emailId,name, andtypeset tonull: there's no email for them, and they can't be read withGET /api/v1/public/emails/{emailId}. - No delay — a delivery shows up in the list right after it's accepted.
Sending keys
/api/v1/public/sending-keys replaces /api/v1/public/external-mails/keys — the same keys, fields, and operations:
- the
sending_keys.readandsending_keys.writescopes instead ofexternal_mail.readandexternal_mail.write; - the error codes
SENDING_KEY_NOT_FOUND,SENDING_KEY_DOMAIN_NOT_FOUND,SENDING_KEY_DOMAIN_NOT_VERIFIED,SENDING_KEY_STATS_INVALID_PERIODinstead ofEXTERNAL_MAIL_KEY_*; an invalid statistics period is400, not422; - the response has no
partOfTokenfield — only the legacy endpoints withX-Api-Keyneed it.
MCP tools
public_emails_sendinstead ofpublic_external_mail_sendandpublic_external_mail_send_by_template; the tool has no idempotency key;public_emails_list_recipientsandpublic_emails_listinstead ofpublic_external_mails_list;public_sending_keys_*instead ofpublic_external_mail_keys_*.
Webhooks
The external_mail.* triggers keep working; you don't need to change existing webhooks. The mapping to email.* events:
| Legacy trigger | New trigger |
|---|---|
external_mail.delivered | email.delivered |
external_mail.hard_bounced, external_mail.soft_bounced | email.bounced with bounceType |
external_mail.error | email.bounced or email.failed — depending on the reason |
| — | email.suppressed |
external_mail.open | email.opened |
external_mail.click | email.clicked |
external_mail.unsubscribe | email.unsubscribed |
external_mail.complaint | email.complained |
The taskId field of the legacy events equals recipient.id in the new ones, so you can move to the new events gradually.
For emails sent through the new endpoint, the legacy triggers only fire for statuses after a send attempt. Rejections at acceptance (suppressed and early rejections by the list of undeliverable addresses) arrive only as email.suppressed and email.bounced events.
Changes in the legacy endpoints
Since ДД.ММ.ГГГГ, the behaviour of the legacy endpoints changed:
-
Quota. If the quota isn't enough for all recipients, the whole email is rejected with
402. -
An SMTP key in
send/{key_id}andsend-by-template/{key_id}returns404 KEY_NOT_FOUND, like a nonexistent key. Use an SMTP key only for SMTP; for HTTP sending, use a key for sending through the API. -
An
emailIdfield appeared in the response: it's the email id forGET /api/v1/public/emails/{emailId}. -
A retry with the same idempotency key of an already accepted email returns the original success response even if the quota has run out.
-
Links
mailto:,tel:, anchors, relative links, and links without a scheme (www.…) are no longer wrapped in the tracking redirect; clicks on them aren't counted. -
Variable substitution:
{{unsubscribe_url}}is substituted intexttoo — including text-only emails, where nothing was substituted before — and in the form with spaces{{ unsubscribe_url }};0and an empty string in/send-by-templateparamsare substituted — previously the placeholder stayed in the email;$&,$1,$$inparamsvalues are substituted literally.
/sendstill has noparamsfield: only the new endpoint substitutes variables into ready-madehtmlandtext. Links intextare not wrapped in the tracking redirect.
If no idempotency key is passed, the legacy endpoints compute one from the sender, recipient, subject, and HTML content: the same email within 20 minutes isn't sent a second time. For text-only emails the content isn't part of the automatic key — two different text emails with the same from, to, and subject within this window collapse into one. The new endpoint has no automatic key — see Behaviour differences.
Migration steps
- Check that the
fromof your emails is in the sending key's domain and the domain is verified. - If you don't want to pass
keyId, check the default key of each domain — theisDefaultfield in the key list. - Convert the request body using the field table and pass
Idempotency-Keyin the header. - Switch response parsing to
data.recipients[]and error handling toerror.code. - Subscribe to
email.*webhooks and move your handlers off the legacy triggers using the mapping table. - Switch history reads to the sending history and key management to
/sending-keys.