Skip to main content

Migrating to the Email API

The new public API email endpoints replace the legacy ones:

Legacy endpointNew
POST /api/v1/external-mails/send, /send-by-templatePOST /api/v1/public/emails
GET /api/v1/public/external-mailsSending 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.* scopesemail.* 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.send scope. A token with the legacy external_mail.send scope works too — see Scopes. The new endpoint only accepts Authorization: Bearer; it doesn't accept the X-Api-Key header.
  • To read emails and the history — the email.read scope; for keys — sending_keys.read and sending_keys.write.

Scopes​

The legacy and the new name are the same permission:

Legacy scopeNew
external_mail.sendemail.send
external_mail.reademail.read and sending_keys.read
external_mail.writesending_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/me returns both names of each permission. If your integration checks for external_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; 202 means the email is accepted for sending.
  • The standard public API format: responses in { ok, data, meta }, errors with codes and per-field details[].
  • One email, all recipients: cc/bcc are arrays, every recipient gets its own id and status in the response, no matching by position.
  • Reading statuses and events by the email id, and email.* webhooks with a reason and bounceType.
  • Default key: keyId can be omitted.
  • Template and ready-made HTML in one endpoint, and the template can be referenced by alias.

Request​

Legacy endpointPOST /api/v1/public/emails
POST /api/v1/external-mails/send/{key_id} or X-Api-KeyPOST /api/v1/public/emails, Bearer token; the key is the keyId field or the default key
/send-by-templateThe same endpoint with templateId or templateAlias
Root mailRoot data
idempotencyKey in the body or Idempotency-KeyOnly the Idempotency-Key header
mail.to — an objectto — an object or an array
mail.cc, mail.bcc — a comma-separated stringcc, bcc — arrays of { email, name? }
mail.previewTitlepreviewText
mail.idTemplateMailUsertemplateId (or templateAlias)
mail.paramsvariables — 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 headersheaders 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 endpointPOST /api/v1/public/emails
{ uuid, additionalRecipients, warning?, emailId } without an envelope202 and { ok, data: { id, status, createdAt, recipients[] }, meta }
uuid — the delivery of the first successful recipientdata.recipients[].id — for every recipient; data.id — the email as a whole
additionalRecipients[] — {uuid} or {error} in their own orderrecipients[] — 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 errorNew error
400 with the violations in message400 BAD_REQUEST with the violations in details[]
402 Is not enough resource on user balance402 PAYMENT_REQUIRED and X-Quota-Remaining
403 API key is not active422 SENDING_KEY_NOT_ACTIVE
404 KEY_NOT_FOUND, 403 KEY_ACCESS_DENIED404 SENDING_KEY_NOT_FOUND
404 User Domain not found404 SENDING_KEY_DOMAIN_NOT_FOUND, 422 SENDER_DOMAIN_MISMATCH, or 422 SENDING_KEY_DOMAIN_NOT_VERIFIED
422 Email receiver doesn't existA 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
503503 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.

All to rejected — copies aren't sent

If 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 status suppressed or bounced. Retrying 422 RECIPIENT_BOUNCED and 422 RECIPIENT_SUPPRESSED is pointless.
  • No quota for rejected recipients — for recipients with status suppressed and bounced in the 202 response.
  • Duplicate addresses across to, cc, bcc return 400 BAD_REQUEST with reason: DUPLICATE_RECIPIENT.
  • Up to 50 recipients across to, cc, and bcc.
  • 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. A 402 response 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 listDelivery list
page, limit, and a totallimit (up to 100) and cursor from the previous response's nextCursor; no total
toEmail, fromEmailto, from
keyId, status, dateFrom, dateToThe same names; status — delivery statuses
id — a numberid — a string, the same delivery id as recipients[].id and taskId in the legacy webhooks; emailId — the email id
recipientemail, 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 as suppressed and failed.
  • 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, and type set to null: there's no email for them, and they can't be read with GET /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.read and sending_keys.write scopes instead of external_mail.read and external_mail.write;
  • the error codes SENDING_KEY_NOT_FOUND, SENDING_KEY_DOMAIN_NOT_FOUND, SENDING_KEY_DOMAIN_NOT_VERIFIED, SENDING_KEY_STATS_INVALID_PERIOD instead of EXTERNAL_MAIL_KEY_*; an invalid statistics period is 400, not 422;
  • the response has no partOfToken field — only the legacy endpoints with X-Api-Key need it.

MCP tools​

  • public_emails_send instead of public_external_mail_send and public_external_mail_send_by_template; the tool has no idempotency key;
  • public_emails_list_recipients and public_emails_list instead of public_external_mails_list;
  • public_sending_keys_* instead of public_external_mail_keys_*.

Webhooks​

The external_mail.* triggers keep working; you don't need to change existing webhooks. The mapping to email.* events:

Legacy triggerNew trigger
external_mail.deliveredemail.delivered
external_mail.hard_bounced, external_mail.soft_bouncedemail.bounced with bounceType
external_mail.erroremail.bounced or email.failed — depending on the reason
—email.suppressed
external_mail.openemail.opened
external_mail.clickemail.clicked
external_mail.unsubscribeemail.unsubscribed
external_mail.complaintemail.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} and send-by-template/{key_id} returns 404 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 emailId field appeared in the response: it's the email id for GET /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 in text too — including text-only emails, where nothing was substituted before — and in the form with spaces {{ unsubscribe_url }};
    • 0 and an empty string in /send-by-template params are substituted — previously the placeholder stayed in the email;
    • $&, $1, $$ in params values are substituted literally.

    /send still has no params field: only the new endpoint substitutes variables into ready-made html and text. Links in text are not wrapped in the tracking redirect.

The automatic idempotency key of the legacy endpoints

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​

  1. Check that the from of your emails is in the sending key's domain and the domain is verified.
  2. If you don't want to pass keyId, check the default key of each domain — the isDefault field in the key list.
  3. Convert the request body using the field table and pass Idempotency-Key in the header.
  4. Switch response parsing to data.recipients[] and error handling to error.code.
  5. Subscribe to email.* webhooks and move your handlers off the legacy triggers using the mapping table.
  6. Switch history reads to the sending history and key management to /sending-keys.

See also​