/ Email API / справочник Swagger UIopenapi.yamlвойти

Email API MailerMail

Транзакционные и массовые письма из вашего кода плюс верификация адресов. Все запросы — JSON поверх HTTPS, ответы — JSON.

Базовый адрес всех методов:

https://api.mailermail.ru/v1

Контракт построен на привычной модели транзакционных Email API (from/to/subject/html/text/attachments) — миграция с типового провайдера во многом сводится к смене хоста и токена; отличия, которые легко пропустить, собраны в разделе «Переезд с UniOne». Машиночитаемая спецификация — openapi.yaml (OpenAPI 3.0), интерактивная версия — Swagger UI.

Проверка доступности

Метод GET /health не требует токена и отвечает {"ok":true} — удобно для мониторинга.

Health
curl https://api.mailermail.ru/v1/health
$body = file_get_contents(
  'https://api.mailermail.ru/v1/health'
);
Ответ200
{
  "ok": true,
  "ts": "2026-08-03T12:00:00+03:00"
}

Аутентификация

Все методы, кроме /health и /unsubscribe, требуют токен. Передайте его в одном из заголовков:

  • Api-Token: mm_live_…
  • Authorization: Bearer mm_live_…

Если переданы оба заголовка, используется Api-Token.

Формат токена — mm_live_ или mm_test_ и затем от 16 до 64 латинских букв и цифр (регулярное выражение ^mm_(live|test)_[A-Za-z0-9]{16,64}$). Токен другого вида отбивается 401 ещё до поиска в базе.

Токены выпускаются в кабинете (Настройки → API). Каждый токен несёт scope:

  • verify — методы верификации (/verify, /verify/batch);
  • send — всё остальное (отправка, шаблоны, стоп-лист, логи, вебхуки, домены).
401Токена нет, он не того формата или неизвестен.
403Токен отозван либо у него нет нужного scope.

Токен с префиксом mm_test_ работает в песочнице — письма не уходят, подробности в разделе «Песочница».

Заголовок токена
curl https://api.mailermail.ru/v1/stats \
  -H "Api-Token: mm_live_9f3k…"
$ch = curl_init('https://api.mailermail.ru/v1/stats');
curl_setopt_array($ch, [
  CURLOPT_HTTPHEADER => ['Api-Token: mm_live_9f3k…'],
  CURLOPT_RETURNTRANSFER => true,
]);
$res = json_decode(curl_exec($ch), true);

Ошибки

Успех — HTTP 2xx и "success": true. Ошибка — соответствующий код и тело {"success": false, "errors": ["…"]}. Массив errors содержит человекочитаемые сообщения (в основном на английском); разбирать их текст в коде не стоит — он может меняться, опирайтесь на HTTP-код.

Ошибка валидации отклоняет запрос целиком: если в /send один адрес из to[] некорректен, не уходит ни одно письмо.

КодЗначение
200Запрос обработан. Для /send неудачи по отдельным получателям — в skipped/failed, код всё равно 200 (подробно — «Ответ /send»).
201Создан шаблон или вебхук.
400Тело не является JSON-объектом.
401Токена нет, он не того формата или неизвестен.
403Токен отозван либо у него нет нужного scope.
404Нет такого эндпоинта или ресурса (шаблон, сообщение, вебхук, домен).
405Метод не поддерживается этим адресом; допустимые перечислены в заголовке Allow.
409Запрос с этим Idempotency-Key ещё выполняется; слаг шаблона занят; шаблон занят идущей рассылкой.
413Тело или вложения превышают лимит.
422Ошибка валидации полей; в том числе Idempotency-Key длиннее 200 символов или уже использованный на другом эндпоинте.
429Превышен лимит запросов. Заголовок Retry-After — секунд до сброса.
500Внутренняя ошибка записи (шаблон сохранён, но не опубликован; вебхук не создан). Текст ошибки говорит, что повторить.
502/send: не ушло ни одно письмо и нет пропущенных адресов. Повтор уместен, если причина временная (таймаут); при постоянном отказе реле он ничего не даст.
503БД недоступна, нет активного SMTP-реле или часть API ещё не развёрнута.
Ответ422 Unprocessable
{
  "success": false,
  "errors": [
    "Field 'to' (non-empty array) is required."
  ]
}

Лимиты запросов

На каждый токен — 120 запросов в минуту. Минута календарная (с :00 до :59 по часам сервера), а не скользящая: 120 запросов в конце одной минуты и 120 в начале следующей пройдут оба.

Считается каждый запрос, прошедший проверку токена и scope, — включая ответы 4xx на ошибки валидации и сами ответы 429. Не считаются /health, /unsubscribe, трекинг и запросы с неверным токеном. При превышении метод отвечает 429 с заголовком Retry-After — число секунд до конца текущей минуты (от 1 до 60).

Лимит рассчитан на пакетную работу, а не на поток по одному письму: 120 запросов в минуту — это до 6000 писем через /send (по 50 получателей) и до 120 000 через /batch.

Отдельные лимиты содержимого:

  • /send — до 50 получателей, вложения ≤10 МБ суммарно, тело ≤15 МБ;
  • /batch — до 500 request'ов, до 1000 писем суммарно;
  • /verify/batch — до 100 адресов.
Ответ429 Too Many Requests
Retry-After: 17

{
  "success": false,
  "errors": [
    "Rate limit exceeded: 120 requests/minute."
  ]
}

Идемпотентность

Методы /send и /batch принимают заголовок Idempotency-Key (произвольная строка ≤200 символов, длиннее — 422). Это защищает от двойной отправки при сетевых ретраях. Ключ действует в пределах токена: у двух токенов одинаковые ключи не пересекаются.

  • Повтор с тем же ключом в течение 24 часов возвращает сохранённые код и тело первого ответа с заголовком Idempotency-Replayed: true. Тело повтора с первым не сравнивается.
  • Сохраняются и ответы 4xx: если первый запрос получил 422, исправленный запрос с тем же ключом снова получит тот же 422. Исправили тело — берите новый ключ. Исключения — ответы, которые отбиваются раньше, чем ключ запоминается: 400 (тело не JSON), 413 за тело больше 15 МБ и 429.
  • Ответы 5xx не сохраняются, включая 502 «не ушло ни одно письмо»: повтор с тем же ключом действительно повторит отправку.
  • Если первый запрос ещё выполняется — параллельный дубль получает 409. Запрос, зависший больше 5 минут, считается брошенным, и ключ можно использовать снова.
  • Тот же ключ на другом эндпоинте (сначала /send, потом /batch) — 422.
  • Ключ один на весь запрос: отдельных ключей на получателей нет. Если из 50 адресов 3 попали в failed, повтор с тем же ключом вернёт тот же ответ, ничего не отправив, — дошлите эти три адреса новым запросом с новым ключом.
Заголовок
curl -X POST https://api.mailermail.ru/v1/send \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Idempotency-Key: order-4211-confirm" \
  -H "Content-Type: application/json" \
  -d '{ … }'
$headers = [
  'Api-Token: mm_live_9f3k…',
  'Idempotency-Key: order-4211-confirm',
  'Content-Type: application/json',
];
Ответ200 повтор
Idempotency-Replayed: true

{
  "success": true,
  "message_ids": ["0b6f7a52-3c1e-4d8a-9f2b-6e1d0c4a7b93"]
}

Песочница

Токен с префиксом mm_test_ прогоняет /send и /batch через весь конвейер, но письма физически не уходят. Остальные методы (шаблоны, стоп-лист, вебхуки, домены) с таким токеном работают по-настоящему — это те же данные аккаунта.

  • Домен отправителя проверяется так же, как на бою: неподтверждённый домен — 422 в /send и ошибка в responses[i] в /batch.
  • Стоп-лист проверяется: адреса из него приходят в skipped, как на бою.
  • Остальные получатели получают message_id и попадают в лог со статусом sandbox (GET /messages?status=sandbox).
  • В ответе есть "sandbox": true.
  • Событий нет вовсе — ни email.sent, ни открытий, ни переходов: вебхуки песочницей не проверить.
  • В счёт отправленных писем (GET /stats → usage.sends) не идёт.
  • Лимит 120 запросов в минуту и Idempotency-Key действуют как обычно.
Ответ200 mm_test_
{
  "success": true,
  "message_ids": ["5d2c9e10-7a4b-4f3e-8c21-0e9b6a1d4f77"],
  "sandbox": true,
  "skipped": [
    {"email": "blocked@example.ru", "reason": "suppressed"}
  ]
}

Отправка письма

POSThttps://api.mailermail.ru/v1/send

Синхронная транзакционная отправка: письмо уходит за секунды, у каждого получателя — своё отдельное письмо и свой message_id. Требует scope send. Домен отправителя должен быть подтверждён в аккаунте (иначе 422) — он же даёт DKIM-подпись.

Задайте subject + html и/или text, либо шаблон через template_id — тогда тему и тело берёт шаблон. Переменные {{var}} подставляются из template_variables и to[].substitutions — правила в разделе «Переменные».

Поля тела

ПолеТипОбяз.Описание
fromobjectдаОтправитель: {email, name?}. Домен — verified. name ≤128 символов.
toarrayдаПолучатели: email, name?, substitutions?; ≤50. Дубли адресов (без учёта регистра) отбрасываются молча — остаётся первое вхождение, но в лимит 50 дубли считаются.
reply_toobjectнетАдрес для ответа {email, name?}.
subjectstring—Тема, ≤255 (длиннее обрезается, переводы строк заменяются пробелами). Обязательна, когда шаблон не задан.
htmlstring—HTML-тело. Нужно html и/или text.
textstring—Текстовое тело. Если не задано — генерируется из HTML.
categorystringнетМетка для группировки в статистике и в событиях, ≤255. С шаблоном и без category берётся категория шаблона.
attachmentsarrayнетПоля: content (base64, ≤10 МБ суммарно после декодирования), filename, type? (вида application/pdf, иначе application/octet-stream), disposition?, content_id?. Значение inline в disposition требует content_id (иначе 422) — в теле на картинку ссылаются через cid:, например src="cid:logo".
template_idstringнетШаблон по UUID или по слагу. См. «Шаблон» ниже.
template_uuidstringнетТо же, но строго UUID. Поле оставлено для действующих интеграций.
template_variablesobjectнетПеременные {{var}} для всех получателей. Значения — скаляры.
headersobjectнетСвои заголовки письма, до 20. См. «Заголовки и метаданные».
custom_variablesobjectнетМетаданные письма, до 10 ключей. В письмо не попадают — возвращаются в событиях вебхука. См. «Заголовки и метаданные».
optionsobjectнетФлаги отправки, см. таблицу ниже.

Поля options

ПолеТипОбяз.Описание
list_unsubscribeboolнетЗаголовки One-Click отписки со ссылкой MailerMail. По умолчанию выключено. См. «Отписка в письме».
unsubscribe_urlstringнетСвой https-адрес отписки в заголовках письма, ≤300 символов после https://.
track_readboolнетПиксель открытия. На /send по умолчанию выключен. См. «Трекинг».
track_linksboolнетОборачивать ссылки для учёта переходов. На /send по умолчанию выключено.

Шаблон

  • template_id принимает UUID или слаг шаблона (слаг — [a-z0-9-], до 64 символов, строчными). template_uuid — только UUID.
  • template_id и template_uuid вместе — 422.
  • С шаблоном нельзя передавать subject, html и text — 422: тему и тело даёт шаблон.
  • Уходит опубликованная версия шаблона. Шаблон, который ни разу не публиковался, — 422 с просьбой опубликовать; неизвестный шаблон — тоже 422. У опубликованной версии нет темы — 422.
  • Без category в запросе берётся категория шаблона.
  • Если не передать ни одной переменной, плейсхолдеры {{…}} шаблона уйдут получателю как есть — см. «Переменные».

Чего нет и что игнорируется

  • Неизвестные поля верхнего уровня игнорируются молча, без ошибки. cc, bcc, metadata и опечатки в именах полей не вызовут 422 — просто ничего не сделают.
  • Копий cc/bcc нет: каждый адрес из to[] получает отдельное письмо, где в To стоит только он. Заголовки Cc и Bcc через headers задать нельзя.
  • options.send_at на /send — 422: отложенная отправка есть только в /batch.
Запрос
curl -X POST https://api.mailermail.ru/v1/send \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": {"email": "no-reply@shop.ru", "name": "Shop"},
    "to": [{"email": "ivan@example.ru"}],
    "subject": "Заказ №4211 собран",
    "html": "<h1>Спасибо за заказ!</h1>"
  }'
$ch = curl_init('https://api.mailermail.ru/v1/send');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'Api-Token: mm_live_9f3k…',
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'from' => ['email' => 'no-reply@shop.ru', 'name' => 'Shop'],
    'to'   => [['email' => 'ivan@example.ru']],
    'subject' => 'Заказ №4211 собран',
    'html' => '<h1>Спасибо за заказ!</h1>',
  ]),
  CURLOPT_RETURNTRANSFER => true,
]);
$res = json_decode(curl_exec($ch), true);
Ответ200
{
  "success": true,
  "message_ids": ["0b6f7a52-3c1e-4d8a-9f2b-6e1d0c4a7b93"]
}
Полный запрос
{
  "from": {"email": "robot@shop.ru", "name": "Shop"},
  "to": [
    {"email": "ivan@example.ru",
     "substitutions": {"name": "Иван"}}
  ],
  "subject": "Заказ №{{order}} собран",
  "html": "<p>{{name}}, заказ №{{order}} ждёт вас.</p>",
  "template_variables": {"order": "4211"},
  "category": "orders",
  "custom_variables": {"order_id": "4211", "user_id": "77"},
  "headers": {"X-Order-Id": "4211"},
  "options": {"list_unsubscribe": true}
}

Ответ /send

Каждый уникальный адрес из to[] получает ровно один исход:

ПолеТипОбяз.Описание
message_idsarray—UUID писем, которые ушли (или прошли песочницу). Всегда присутствует, может быть пустым.
skippedarray—{email, reason} — адреса, на которые письмо не отправлялось. Ключа нет, если пропущенных нет.
failedarray—{email, reason} — адреса, которые отправить не удалось. Ключа нет, если неудач нет.
sandboxbool—Только у токенов mm_test_, всегда true.

message_ids не совпадают с to[] по позиции

message_id получают только ушедшие письма; пропущенные и неудачные адреса его не получают. Поэтому message_ids[i] — это не to[i], как только хоть один адрес попал в skipped или failed или в to[] был дубль.

Порядок ушедших при этом сохраняется. Сопоставить можно так: уберите из to[] дубли (без учёта регистра, остаётся первое вхождение), затем адреса из skipped[] и failed[] — оставшиеся адреса по порядку соответствуют message_ids. Если нужен надёжный ключ «письмо ↔ ваша запись», передайте его в custom_variables — он вернётся в каждом событии письма — или отправляйте по одному получателю на запрос.

Причины skipped

suppressedАдрес в стоп-листе вашего аккаунта. Настоящую причину (отписка, жалоба, отказ, ручное добавление) покажет GET /suppressions?email=…; снять — DELETE /suppressions.
known_bounceАдрес в общем реестре недоставляемых MailerMail: за последние 24 месяца почтовый сервер отвечал, что такого ящика нет. Через API не снимается; если адрес снова жив, верните его в кабинете: Контакты → Заблокированные. Сейчас такой возврат действует только на /send: /batch продолжает пропускать адрес с known_bounce.

Если адрес подходит под обе причины, в ответе будет suppressed.

Причины failed

reason — произвольный текст для человека, не перечисление:

  • rendered body over 3 MB — после подстановки переменных HTML и текст вместе больше 3 МБ;
  • timeout: send budget exceeded, retry these recipients — на весь запрос отводится 60 секунд, и на медленных реле до части адресов очередь не дошла: письма им не отправлялись, их можно слать повторно;
  • текст ошибки SMTP (до 254 символов), например SMTP Error: data not accepted. Это ответ нашего реле, а не сервера получателя: /send передаёт письмо реле, а отказ почтового ящика (переполнен, не существует) приходит позже и в failed[] не попадает.

Коды ответа

HTTPsuccessИсход
200trueвсе письма ушли
200trueчасть ушла, часть в skipped/failed
200trueвсе адреса пропущены: пустой message_ids и skipped
200trueничего не ушло, но есть и failed, и skipped
502falseничего не ушло, все адреса в failed, пропущенных нет
200trueпесочница, в ответе есть sandbox
4xxfalseошибка валидации — не ушло ничего, текст в errors
503falseнет активного SMTP-реле

"success": true значит «запрос обработан», а не «все письма ушли»: проверяйте failed и skipped всегда. После 502 и 503 письма не ушли и ответ не сохранён под Idempotency-Key, поэтому весь запрос можно повторить как есть, с тем же ключом. Если в 502 причина — постоянный отказ реле (код 5xx в reason), повтор не поможет.

message_id ушедшего письма совпадает с message_id в его событиях вебхука и в GET /messages/{uuid}. У пропущенных и неудачных адресов тоже есть свои записи в логе и события email.suppressed/email.failed, но их id в ответе /send не приходит.

Ответ200 частично
{
  "success": true,
  "message_ids": [
    "0b6f7a52-3c1e-4d8a-9f2b-6e1d0c4a7b93",
    "8e3a1f04-52d7-4b6c-a0e9-3f7c2d19b845"
  ],
  "skipped": [
    {"email": "old@dead.ru", "reason": "known_bounce"},
    {"email": "gone@example.ru", "reason": "suppressed"}
  ],
  "failed": [
    {"email": "full@example.ru",
     "reason": "SMTP Error: data not accepted."}
  ]
}
Ответ200 все пропущены
{
  "success": true,
  "message_ids": [],
  "skipped": [
    {"email": "gone@example.ru", "reason": "suppressed"}
  ]
}
Ответ502 ничего не ушло
{
  "success": false,
  "message_ids": [],
  "failed": [
    {"email": "ivan@example.ru",
     "reason": "timeout: send budget exceeded, retry these recipients"}
  ]
}

Переменные {{var}}

Плейсхолдер {{name}} в subject, html и text заменяется значением переменной. Работает одинаково в письме из шаблона и в письме, собранном прямо в запросе: шаблон не обязателен.

  • Имя — [A-Za-z0-9_.], от 1 до 64 символов; пробелы внутри скобок допустимы ({{ name }}). Плейсхолдер с другим именем ({{Имя}}, {{user-name}}) не распознаётся и остаётся в письме как есть.
  • Источники: template_variables — для всех получателей, to[].substitutions — для одного. Значение получателя перекрывает общее; null в substitutions не перекрывает.
  • Значения — скаляры: строка, число, true/false, null. Объект или массив — 422. true подставится как 1, false и null — как пустая строка. Значение ≤64 КБ, все значения одного словаря вместе ≤256 КБ.
  • Значения вставляются без экранирования. Если значение приходит от пользователя и попадает в HTML, экранируйте его сами (htmlspecialchars), иначе < и & сломают вёрстку.

Подстановка включается только переменными

Плейсхолдеры обрабатываются, только если в запросе есть хотя бы одна переменная — непустой template_variables или непустой substitutions у кого-то из получателей. Без переменных все {{…}} уходят получателю дословно, в том числе из шаблона: это сделано, чтобы не портить тела, где двойные скобки — часть текста.

Неизвестная переменная — пустая строка

Если переменные переданы, а для какого-то плейсхолдера значения нет, он превращается в пустую строку, без ошибки и без предупреждения. Опечатка в имени ({{nmae}}) даёт «Здравствуйте, !». Проверяйте, что передаёте все переменные шаблона.

При переезде с UniOne это ловушка: там неизвестная переменная остаётся в письме как есть, и опечатку видно в тестовом письме; здесь она молча исчезает.

Служебных переменных нет. {{unsubscribe_url}}, {{email}} и подобные — обычные имена: без своего значения они станут пустой строкой. Ссылку отписки в тело нужно вставлять самим — см. «Отписка в письме».

Запрос
{
  "from": {"email": "robot@shop.ru"},
  "to": [
    {"email": "anna@example.ru",
     "substitutions": {"name": "Анна", "bonus": 500}},
    {"email": "boris@example.ru",
     "substitutions": {"name": "Борис"}}
  ],
  "subject": "{{name}}, у вас {{bonus}} бонусов",
  "text": "Бонусы сгорают {{date}}. {{promo}}",
  "template_variables": {"bonus": 100, "date": "01.10"}
}
Что получат
anna@example.ru
  Тема:  Анна, у вас 500 бонусов
  Текст: Бонусы сгорают 01.10.

boris@example.ru
  Тема:  Борис, у вас 100 бонусов
  Текст: Бонусы сгорают 01.10.

{{promo}} не передан ни разу — пустая строка.

Заголовки и метаданные

custom_variables — метаданные письма

Произвольные пары «ключ — значение», которые не попадают в само письмо, а возвращаются в data.custom_variables событий вебхука. Это главный способ связать событие с записью в вашей системе: положите сюда id заказа или пользователя.

  • До 10 ключей. Ключ — буквы, цифры, _, ., -, от 1 до 64 символов; ключ из одних цифр ("123") не принимается.
  • Значения — только скаляры, и они приводятся к строке: 42 → "42", true → "true", false → "false", null → "". Объект или массив — 422.
  • Всё вместе в виде JSON — не больше 2000 байт (кириллица — 2 байта на букву).
  • Одно значение на запрос, а не на получателя: у всех писем одного /send метаданные одинаковые. Разные метаданные на разных получателей — разными запросами или через requests[] в /batch.

Возвращаются в событиях email.sent, email.failed, email.suppressed, email.opened, email.clicked. Не возвращаются в email.bounced и email.unsubscribed: эти события подбирают письмо по адресу получателя, а не по самому письму (см. «Типы событий»).

headers — свои заголовки письма

  • До 20 заголовков. Имя — латиница, цифры и дефис, начинается с буквы или цифры, до 64 символов.
  • Значение — строка (число и true/false тоже примутся и станут "1"/""; null — 422), без переводов строк, до 900 байт. Все заголовки вместе в виде JSON — не больше ~1900 байт.
  • Служебные заголовки задать нельзя — 422 (регистр не важен): From, To, Cc, Bcc, Subject, Reply-To, Sender, Return-Path, Message-ID, Date, MIME-Version, Content-Type, Content-Transfer-Encoding, Content-Disposition, DKIM-Signature, DomainKey-Signature, Received, X-Mailer, List-Unsubscribe, List-Unsubscribe-Post, List-Id, List-Post, List-Help, List-Owner, List-Archive, Feedback-ID, Disposition-Notification-To, Return-Receipt-To, Errors-To, Read-Receipt-To, Resent-From, Resent-To, Resent-Cc, Resent-Sender, Resent-Date, Resent-Message-ID.

Отписку в заголовках включают не через headers, а через options.list_unsubscribe или options.unsubscribe_url.

Запрос
{
  "…": "…",
  "custom_variables": {
    "order_id": 4211,
    "vip": true,
    "coupon": null
  },
  "headers": {
    "X-Order-Id": "4211",
    "X-Entity-Ref-ID": "order-4211"
  }
}
Что вернётся в событии
"data": {
  "custom_variables": {
    "order_id": "4211",
    "vip": "true",
    "coupon": ""
  }
}
Ответ422
{
  "success": false,
  "errors": ["header 'List-Id' is reserved."]
}

Отписка в письме

Кнопку «Отписаться» почтовые клиенты (Gmail, Яндекс, Mail.ru) рисуют по заголовкам List-Unsubscribe и List-Unsubscribe-Post (RFC 8058, One-Click). На /send заголовки по умолчанию не ставятся; включить их можно двумя способами — они по-разному решают, кто узнаёт об отписке.

list_unsubscribe: true — отписку ведёт MailerMail

  • В заголовке — ссылка MailerMail вида https://mailermail.ru/api/v1/u/{token}. Токен один на пару «аккаунт — адрес»: у всех писем одному человеку ссылка одинаковая.
  • One-Click (POST от почтового клиента) обрабатывает MailerMail: адрес уходит в стоп-лист с причиной unsubscribed, приходит событие email.unsubscribed, а контакт с этим адресом в кабинете помечается отписавшимся.
  • Дальше /send и /batch пропускают этот адрес (skipped, suppressed). Вернуть — DELETE /suppressions.
  • GET по этой ссылке (человек открыл её в браузере) показывает страницу с кнопкой подтверждения и сам по себе ничего не меняет: по ссылкам ходят сканеры почтовых шлюзов.

unsubscribe_url — отписку ведёте вы

  • Оба заголовка ведут на ваш адрес: List-Unsubscribe: <ваш URL> и List-Unsubscribe-Post: List-Unsubscribe=One-Click. Почтовый клиент шлёт POST на ваш URL с телом List-Unsubscribe=One-Click — ваш обработчик должен его принять.
  • MailerMail об этой отписке не узнаёт: ни записи в стоп-листе, ни события. Если хотите, чтобы сервис сам не слал на этот адрес, добавьте его в стоп-лист (POST /suppressions с reason: "unsubscribed").
  • list_unsubscribe при этом передавать не нужно — unsubscribe_url включает заголовки сам.
  • Только https://, после схемы — от 5 до 300 байт без пробельных символов, двойных кавычек и угловых скобок, иначе 422. mailto: не поддерживается. Используйте только ASCII: кириллицу в адресе кодируйте (%D0%…), иначе заголовок уйдёт закодированным и почтовики его не распознают.

В тело письма ничего не вставляется

Оба способа ставят только заголовки. Ссылки отписки в тексте письма MailerMail не добавляет, плейсхолдера {{unsubscribe_url}} нет — если передаёте переменные, он превратится в пустую строку, если нет — останется в письме дословно. Для массовых писем ссылку в теле ставьте сами, на свою страницу.

В /batch заголовки со ссылкой MailerMail включены по умолчанию и выключаются options.skip_unsubscribe: true; своего unsubscribe_url там нет.

list_unsubscribe: true
List-Unsubscribe: <https://mailermail.ru/api/v1/u/3fa9c2e17b04d6a8>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
unsubscribe_url
List-Unsubscribe: <https://shop.ru/unsub?u=77&sig=9c1e>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Что придёт на ваш URL
POST /unsub?u=77&sig=9c1e HTTP/1.1
Host: shop.ru
Content-Type: application/x-www-form-urlencoded

List-Unsubscribe=One-Click

Массовая отправка

POSThttps://api.mailermail.ru/v1/batch

Постановка партии писем в очередь: message_ids выдаются сразу, реально отправляет фоновый воркер (латентность до ~5 минут). Объект base задаёт общие поля, поля каждого request их перекрывают. Вложения не поддерживаются — используйте /send.

HTTP всегда 200 (кроме структурных ошибок тела): пер-письмо статусы — в массиве responses[] в порядке requests.

Поля тела

ПолеТипОбяз.Описание
baseobjectнетОбщие поля письма (как в /send, без attachments).
requestsarrayдаМассив писем (поля /send), ≤500; суммарно ≤1000 писем.
options.send_atstringнетОтложить: YYYY-MM-DD HH:MM:SS UTC, окно ≤72 ч.
options.skip_unsubscribeboolнетОтключить List-Unsubscribe (в bulk он включён по умолчанию).
Запрос
curl -X POST https://api.mailermail.ru/v1/batch \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{
    "base": {
      "from": {"email": "news@shop.ru"},
      "subject": "Скидки недели",
      "html": "<p>Привет, {{name}}!</p>"
    },
    "requests": [
      {"to": [{"email": "a@ex.ru", "substitutions": {"name": "Анна"}}]},
      {"to": [{"email": "b@ex.ru", "substitutions": {"name": "Борис"}}]}
    ]
  }'
$payload = [
  'base' => [
    'from' => ['email' => 'news@shop.ru'],
    'subject' => 'Скидки недели',
    'html' => '<p>Привет, {{name}}!</p>',
  ],
  'requests' => [
    ['to' => [['email' => 'a@ex.ru', 'substitutions' => ['name' => 'Анна']]]],
    ['to' => [['email' => 'b@ex.ru', 'substitutions' => ['name' => 'Борис']]]],
  ],
];
Ответ200
{
  "success": true,
  "responses": [
    {"success": true, "message_ids": ["…"]},
    {"success": true, "message_ids": ["…"]}
  ]
}

Домены отправителя

GET/domains
GET/domains/{domain}

Отправлять можно только с подтверждённого домена: если домен из from не проверен, /send отвечает 422, а /batch — ошибкой в responses[i] для каждого такого письма. Это защита от подделки отправителя и требование почтовых провайдеров — без SPF и DKIM письма уходят в спам.

Как подключить домен

  1. В кабинете: Настройки → Домены отправителя → Добавить. Мы сгенерируем пару ключей DKIM.
  2. Опубликуйте три TXT-записи в DNS вашего домена (их отдаёт и этот метод, и карточка домена в кабинете).
  3. Нажмите «Проверить DNS». Записи расходятся до 48 часов — если проверка не прошла сразу, повторите позже.

Добавление и проверка живут в кабинете намеренно: это разовое действие человека с доступом к DNS, а не то, что автоматизируют из кода. Через API домены доступны только на чтение — чтобы интеграция могла убедиться, что домен готов, до отправки, а не ловить 422 в бою.

Статусы

ПолеТипОбяз.Описание
verifiedстатус—Все три записи на месте — можно отправлять.
pending_dnsстатус—Домен добавлен, записи ещё не найдены.
failedстатус—Записи были, но пропали: домен перестал проходить суточную перепроверку. Отправка с него не пойдёт, пока записи не вернутся.

Поля spf, dkim, dmarc показывают, какая именно запись не найдена. checked_at — время последней проверки; у домена, который ещё ни разу не проверялся, он равен null. Раз в сутки мы сами перепроверяем только домены в статусе verified и failed. Домен в pending_dns сам в verified не перейдёт — после публикации записей нажмите «Проверить DNS» в кабинете; опрашивать API в ожидании бесполезно. Домен, простоявший в pending_dns больше 7 дней, удаляется, и GET /domains/{domain} начинает отвечать 404. Метки времени здесь — ISO 8601 со смещением.

GET /domains/{domain} дополнительно отдаёт dns_records — три TXT-записи, которые нужно опубликовать, с подсказкой note к каждой. Записи приходят и для уже подтверждённого домена: пригодится при переезде к другому DNS-провайдеру. Длинный ключ DKIM разбит на части по 255 символов в кавычках — так его принимает большинство DNS-панелей. Метод читает сохранённый результат проверки и сам DNS не опрашивает. Домена нет в аккаунте — 404.

Рекомендуем отдельный поддомен для рассылок (например mail.brand.ru): он изолирует репутацию рассылок от корпоративной почты — проблемы с одним потоком не утянут другой.

Кириллические домены можно запрашивать как есть — /domains/почта.рф и /domains/xn--80a1acny.xn--p1ai вернут одно и то же.

Список доменов
curl https://api.mailermail.ru/v1/domains \
  -H "Api-Token: mm_live_9f3k…"
$ch = curl_init('https://api.mailermail.ru/v1/domains');
curl_setopt_array($ch, [
  CURLOPT_HTTPHEADER => ['Api-Token: mm_live_9f3k…'],
  CURLOPT_RETURNTRANSFER => true,
]);
$res = json_decode(curl_exec($ch), true);

// можно ли слать с этого домена
$ok = false;
foreach ($res['domains'] as $d) {
    if ($d['domain'] === 'mail.brand.ru') {
        $ok = ($d['status'] === 'verified');
    }
}
Ответ200
{
  "success": true,
  "total": 1,
  "domains": [
    {
      "domain": "mail.brand.ru",
      "status": "verified",
      "spf": true,
      "dkim": true,
      "dmarc": true,
      "is_default": true,
      "checked_at": "2026-08-03T06:00:11+03:00",
      "created_at": "2026-07-20T12:31:00+03:00"
    }
  ]
}
Один домен + записи DNS
curl https://api.mailermail.ru/v1/domains/mail.brand.ru \
  -H "Api-Token: mm_live_9f3k…"
Ответ200
{
  "success": true,
  "domain": {
    "domain": "mail.brand.ru",
    "status": "pending_dns",
    "spf": true,
    "dkim": false,
    "dmarc": false,
    "is_default": false,
    "checked_at": null,
    "created_at": "2026-09-18T11:02:40+03:00",
    "dns_records": [
      {"type": "TXT", "name": "@",
       "value": "v=spf1 include:_spf.mailermail.ru ~all",
       "note": "SPF. Если запись v=spf1 уже есть — допишите include в неё, второй SPF недопустим."},
      {"type": "TXT", "name": "mm1._domainkey",
       "value": "\"v=DKIM1; k=rsa; p=MIIBIjANBg…\" \"…IDAQAB\"",
       "note": "DKIM. Часть провайдеров требует значение без кавычек — тогда склейте чанки в одну строку."},
      {"type": "TXT", "name": "_dmarc",
       "value": "v=DMARC1; p=none; rua=mailto:dmarc@feedback.mailermail.ru; adkim=r; aspf=r",
       "note": "DMARC. p=none — режим наблюдения, доставку не блокирует; политику можно ужесточить позже."}
    ]
  }
}
Ответ404
{
  "success": false,
  "errors": [
    "Domain not found. Add it in the cabinet: Настройки → Домены отправителя."
  ]
}

Верификация адресов

POSThttps://api.mailermail.ru/v1/verify
POSThttps://api.mailermail.ru/v1/verify/batch

Проверка адреса без отправки письма: синтаксис, MX/A домена, ролевые и одноразовые адреса, глобальный реестр недоставляемых, подсказка опечаток. Требует scope verify. Пакетом — до 100 адресов.

Поле тела

ПолеТипОбяз.Описание
emailstringдаОдин адрес (для /verify).
emailsarrayдаМассив адресов ≤100 (для /verify/batch).

Статусы результата

deliverableДомен принимает почту, признаков риска нет.
riskyРолевой/одноразовый адрес либо вероятная опечатка домена.
undeliverableСинтаксис неверен, домен мёртв или адрес в реестре отказов.
unknownДомен не удалось проверить (временно).
Запрос
curl -X POST https://api.mailermail.ru/v1/verify \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{"email": "ivan@example.ru"}'
$payload = ['email' => 'ivan@example.ru'];
// POST на /api/v1/verify с заголовком Api-Token
Ответ200
{
  "success": true,
  "email": "ivan@example.ru",
  "status": "deliverable",
  "checks": {
    "syntax": true,
    "domain": "ok",
    "role": false,
    "disposable": false,
    "known_bounce": false,
    "did_you_mean": null
  }
}

Шаблоны

GET/templates
POST/templates
GETPATCHDELETE/templates/{uuid}
POST/templates/{uuid}/publish

Хранимые шаблоны письма с переменными {{var}} в теме и теле. При отправке подставляются через template_id (или template_uuid) + template_variables/substitutions. Требует scope send. Лимит — 5000 шаблонов на аккаунт.

Хранилище общее с личным кабинетом. Шаблон, созданный по API, виден и редактируется в разделе «Шаблоны», а письмо, свёрстанное в кабинете, доступно по API — поле source показывает, откуда строка. Поэтому у шаблона есть версии: send и batch отправляют опубликованную версию, а не текущий черновик. Через API шаблон публикуется сам — при создании и при правке subject/html/text; публиковать вручную нужно только то, что правили в кабинете.

Поля создания / обновления

ПолеТипОбяз.Описание
namestringдаНазвание, ≤128. Обязательно при создании.
subjectstringнетТема (с {{var}}), ≤255.
htmlstring—HTML-тело, ≤2 МБ. Нужно html и/или text.
textstring—Текстовое тело, ≤2 МБ.
categorystringнетМетка по умолчанию, ≤255.
slugstringнетЧитаемый алиас для template_id: [a-z0-9-], ≤64. Уникален внутри аккаунта (занятый — 409), в форме UUID запрещён. Переименование ломает интеграции — постоянный ключ шаблона это uuid.
publishboolнетПо умолчанию true. false оставит черновик — отправить им нельзя.

Методы

  • GET /templates — список, новые первыми: ?limit= от 1 до 100 (по умолчанию 50), ?offset=. Тел в списке нет. uuid может быть null — у старого письма из кабинета, которому не удалось выдать ключ; такой шаблон по API не адресуется.
  • GET /templates/{uuid} — шаблон целиком, с html/text черновика и признаком has_unpublished_changes.
  • POST /templates — создать, ответ 201 с объектом template.
  • PATCH /templates/{uuid} — обновить любое подмножество полей; после правки обязано остаться хотя бы одно тело. Ответ без обёртки template и без uuid — только состояние после правки: status, version, has_unpublished_changes.
  • POST /templates/{uuid}/publish — опубликовать текущий черновик, тело не нужно.
  • DELETE /templates/{uuid} — удалить, ответ {"success": true}.

В пути — только UUID. Слаг адресует шаблон лишь в template_id при отправке: GET /templates/order-ok вернёт 404. Храните у себя uuid.

Когда правка публикуется

  • Правка subject, html или text публикуется сама, если не передать "publish": false. Если новое содержимое совпадает с опубликованным, новой версии не будет.
  • Правка только name, category или slug не публикуется: иначе переименование по API отправило бы подписчикам недописанный в кабинете черновик.
  • Если тело сохранилось, а публикация не прошла, PATCH отвечает 500 — повторите POST /templates/{uuid}/publish. До этого уходит прежняя версия.
  • Тело шаблона, собранного в блочном конструкторе кабинета, по API не правится — 422; имя, тему, категорию и слаг — можно.

Шаблон, за который держится рассылка, запущенная до появления версий, править и удалять нельзя (409): она читает его на каждом письме. Занятый слаг — тоже 409. Метки created_at/updated_at у шаблонов — в формате YYYY-MM-DD HH:MM:SS по московскому времени, без смещения.

html/text в ответе — это черновик, а отправляется опубликованная версия. Их видно расходящимися, когда письмо правят в кабинете и не публикуют. Признак — has_unpublished_changes в GET и в ответе PATCH: если он true, уйдёт не то тело, что вы прочитали, — опубликуйте (POST /templates/{uuid}/publish). Само по себе status: "published" говорит лишь о том, что опубликованная версия существует.

Создание
curl -X POST https://api.mailermail.ru/v1/templates \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Подтверждение заказа",
    "subject": "Заказ №{{order}} принят",
    "html": "<p>{{name}}, спасибо!</p>",
    "slug": "order-ok"
  }'
$payload = [
  'name' => 'Подтверждение заказа',
  'subject' => 'Заказ №{{order}} принят',
  'html' => '<p>{{name}}, спасибо!</p>',
  'slug' => 'order-ok',
];
Ответ201
{
  "success": true,
  "template": {
    "uuid": "7c9e6a3b-1f2d-4c5e-8a9b-0d1e2f3a4b5c",
    "name": "Подтверждение заказа",
    "subject": "Заказ №{{order}} принят",
    "category": "",
    "created_at": "2026-09-18 12:00:00",
    "updated_at": "2026-09-18 12:00:00",
    "slug": "order-ok",
    "source": "api",
    "status": "published",
    "version": 1
  }
}
Ответ200 GET /templates
{
  "success": true,
  "total": 2267,
  "templates": [
    {
      "uuid": "7c9e6a3b-1f2d-4c5e-8a9b-0d1e2f3a4b5c",
      "name": "Подтверждение заказа",
      "subject": "Заказ №{{order}} принят",
      "category": "",
      "created_at": "2026-09-18 12:00:00",
      "updated_at": "2026-09-18 12:00:00",
      "slug": "order-ok",
      "source": "api",
      "status": "published",
      "version": 1
    },
    {
      "uuid": null,
      "name": "Рассылка 2019",
      "subject": "",
      "category": "",
      "created_at": "2019-03-01 10:00:00",
      "updated_at": "2019-03-01 10:00:00",
      "slug": null,
      "source": "ui",
      "status": "draft",
      "version": 0
    }
  ]
}
Ответ200 GET /templates/{uuid}
{
  "success": true,
  "template": {
    "uuid": "7c9e6a3b-1f2d-4c5e-8a9b-0d1e2f3a4b5c",
    "name": "Подтверждение заказа",
    "subject": "Заказ №{{order}} принят",
    "category": "",
    "created_at": "2026-09-18 12:00:00",
    "updated_at": "2026-09-18 12:30:00",
    "slug": "order-ok",
    "source": "api",
    "status": "published",
    "version": 1,
    "has_unpublished_changes": false,
    "html": "<p>{{name}}, спасибо!</p>",
    "text": ""
  }
}
Правка тела
PATCH /v1/templates/7c9e6a3b-…

{"html": "<p>{{name}}, заказ принят!</p>"}
Ответ200 PATCH
{
  "success": true,
  "status": "published",
  "version": 2,
  "has_unpublished_changes": false
}
Ответ200 POST …/publish
{
  "success": true,
  "status": "published",
  "version": 3
}
Отправка по шаблону
# отправка по слагу: на стенде и на бою он одинаковый
curl -X POST https://api.mailermail.ru/v1/send \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{
    "from": {"email": "robot@example.ru"},
    "to": [{"email": "user@example.ru"}],
    "template_id": "order-ok",
    "template_variables": {"order": "1042", "name": "Иван"}
  }'
$payload = [
  'from' => ['email' => 'robot@example.ru'],
  'to' => [['email' => 'user@example.ru']],
  'template_id' => 'order-ok', // uuid тоже принимается
  'template_variables' => ['order' => '1042', 'name' => 'Иван'],
];
Удаление
curl -X DELETE \
  https://api.mailermail.ru/v1/templates/7c9e6a… \
  -H "Api-Token: mm_live_9f3k…"
// DELETE /api/v1/templates/{uuid}
// с заголовком Api-Token
Ответ200 DELETE
{
  "success": true
}

Стоп-лист (suppressions)

GET/suppressions
POSTDELETE/suppressions

Адреса, на которые сервис не отправит письмо: /send и /batch отдают их в skipped с причиной suppressed. Пополняется автоматически (отказы, жалобы, отписки) и вручную через API. Требует scope send. Адреса хранятся в нижнем регистре.

  • GET /suppressions — список, новые первыми: ?limit= от 1 до 1000 (по умолчанию 100), ?offset=, ?reason=. Ответ — {success, total, suppressions: [{email, reason, created_at}]}.
  • GET /suppressions?email=x@y.ru — проверка одного адреса: {success, email, suppressed, reason, created_at}; если адреса в списке нет — suppressed: false, reason и created_at равны null. Некорректный адрес — 422.
  • POST — добавить {emails: […≤1000], reason?}. Ответ — {success, added, reason, invalid?}. added — число обработанных корректных адресов, включая уже бывшие в списке; причина у них перезаписывается переданной (по умолчанию manual).
  • DELETE — убрать (снять блокировку) {emails: […≤1000]}. Ответ — {success, removed, invalid?}, где removed — сколько адресов действительно было в списке.

Некорректные адреса в POST/DELETE не прерывают запрос — они перечислены в invalid. Если корректных нет вовсе — 422 с тем же invalid. Дубли внутри запроса схлопываются. created_at — формат YYYY-MM-DD HH:MM:SS, московское время.

DELETE снимает только стоп-лист аккаунта. Причину known_bounce (общий реестр недоставляемых) через API не снять — только в кабинете: Контакты → Заблокированные, и сейчас это действует только на /send.

Причины (reason)

manualДобавлен вручную (по умолчанию при POST).
hard_bounceПочтовый сервер ответил, что ящика или домена нет. Пишется автоматически, но сейчас — только по отказам рассылок из кабинета, а не писем Email API (см. email.bounced в «Типах событий»).
complainedЖалоба на спам через FBL почтового провайдера. Пишется автоматически. Фильтр ?reason=complained сейчас отвечает 422 — такие строки видны в общем списке и в проверке ?email=.
complaintПринимается в POST и в фильтре ?reason=; автоматически сейчас не пишется — жалобы приходят с причиной complained.
unsubscribedОтписка по One-Click-ссылке MailerMail (options.list_unsubscribe или /batch).
Добавление
curl -X POST https://api.mailermail.ru/v1/suppressions \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["a@ex.ru", "b@ex.ru"], "reason": "manual"}'
$payload = [
  'emails' => ['a@ex.ru', 'b@ex.ru'],
  'reason' => 'manual',
];
Ответ200 POST
{
  "success": true,
  "added": 2,
  "reason": "manual",
  "invalid": ["not-an-email"]
}
Ответ200 GET ?email=
{
  "success": true,
  "email": "a@ex.ru",
  "suppressed": true,
  "reason": "unsubscribed",
  "created_at": "2026-09-17 21:14:05"
}
Ответ200 GET ?email= — не в списке
{
  "success": true,
  "email": "new@ex.ru",
  "suppressed": false,
  "reason": null,
  "created_at": null
}
Ответ200 GET список
{
  "success": true,
  "total": 3,
  "suppressions": [
    {"email": "a@ex.ru", "reason": "unsubscribed",
     "created_at": "2026-09-17 21:14:05"},
    {"email": "spam@ex.ru", "reason": "complained",
     "created_at": "2026-09-10 08:00:12"},
    {"email": "b@ex.ru", "reason": "manual",
     "created_at": "2026-09-01 12:00:00"}
  ]
}
Ответ200 DELETE
{
  "success": true,
  "removed": 1
}

Логи и статистика

GET/messages
GET/messages/{uuid}
GET/stats

Лог отправок хранится 30 дней. Требует scope send.

Фильтры GET /messages

ПолеТипОбяз.Описание
limit / offsetintнетПагинация (limit ≤200, по умолч. 50).
statusenumнетsent, sandbox, skipped, failed, queued.
categorystringнетФильтр по метке.
emailstringнетФильтр по получателю.
from / tostringнетYYYY-MM-DD или с временем.

GET /messages/{uuid} возвращает одно сообщение, а для писем из /batch — ещё и состояние очереди (попытки, время отправки). GET /stats?days=7|30 отдаёт агрегаты по статусам, дням и категориям + суммарный объём проверок и отправок.

Лог
curl "https://api.mailermail.ru/v1/messages?status=sent&limit=20" \
  -H "Api-Token: mm_live_9f3k…"
$url = 'https://api.mailermail.ru/v1/messages'
     . '?status=sent&limit=20';
// GET с заголовком Api-Token
Ответ200
{
  "success": true,
  "total": 128,
  "messages": [
    {
      "message_id": "a1b2c3-…",
      "to": "ivan@example.ru",
      "from": "no-reply@shop.ru",
      "subject": "Заказ №4211 собран",
      "category": "orders",
      "status": "sent",
      "reason": "",
      "sandbox": false,
      "created_at": "2026-08-03 12:00:01"
    }
  ]
}
Статистика
curl "https://api.mailermail.ru/v1/stats?days=30" \
  -H "Api-Token: mm_live_9f3k…"
$url = 'https://api.mailermail.ru/v1/stats?days=30';

Отписка

GETPOSThttps://api.mailermail.ru/v1/unsubscribe

Публичный endpoint по стандарту RFC 8058 (One-Click). Токен не нужен: длинную ссылку /unsubscribe?t=&e=&s= санкционирует HMAC-подпись, короткую /u/{token}, которая стоит в заголовках, — случайный токен, выданный на пару «аккаунт — адрес». Его вызывают почтовые клиенты (кнопка «Отписаться» в Gmail/Yandex) и получатели.

Ссылка автоматически проставляется в заголовок List-Unsubscribe при массовой отправке (в /batch — по умолчанию; в /send — по options.list_unsubscribe). POST добавляет адрес в стоп-лист (reason=unsubscribed), снимает подписку в кабинете и порождает событие email.unsubscribed; GET отдаёт человеку страницу подтверждения и ничего не меняет. Когда отписку ведёт ваш собственный unsubscribe_url, этот endpoint не участвует — см. «Отписка в письме».

Самостоятельно формировать эти ссылки не нужно — сервис вставляет их сам. В письмах они ведут на основной домен (https://mailermail.ru/api/v1/u/…), а не на api.: получателю привычнее видеть знакомый адрес, и репутация этих ссылок уже набрана.

Ответ200 One-Click POST
{
  "success": true,
  "unsubscribed": true
}

Вебхуки

GETPOST/webhooks
GETPATCHDELETE/webhooks/{uuid}
POST/webhooks/{uuid}/rotate_secret

Мы шлём POST на ваш адрес пачками до 500 событий, очередь разбирается каждую минуту. Требует scope send; до 20 вебхуков на аккаунт (21-й — 422). Управлять ими можно и в кабинете: Настройки → Вебхуки.

Поля создания и правки

ПолеТипОбяз.Описание
urlstringдаАдрес приёмника, ≤512 символов. Схемы http/https, порты 80, 443, 8080 и 8443, без логина и пароля в адресе. Приватные и зарезервированные адреса (127.0.0.1, 10.x, 192.168.x, облачная метадата), localhost, *.local, *.internal и домены самого MailerMail отклоняются — и при создании, и перед каждой доставкой. В ответе адрес нормализован: без порта по умолчанию, без #фрагмента, пустой путь заменён на /.
eventsarrayнетТипы событий (см. «Типы событий»). Поле не передано — подписка на все типы, включая те, что появятся позже; перечислить все восемь — то же самое. Пустой массив или неизвестный тип — 422.
enabledboolнетfalse — создать выключенным. Передавайте JSON-boolean: строка "false" будет прочитана как «включить».

Ответы

  • POST /webhooks → 201: объект webhook, а секрет — на верхнем уровне, в поле secret. Идентификатор вебхука — webhook.webhook_id (не uuid); его и подставляйте в путь /webhooks/{uuid}.
  • GET /webhooks → {success, total, webhooks: […]}, новые первыми.
  • GET /webhooks/{uuid} → {success, webhook, queue}; queue — счётчики строк очереди доставки: pending (ждут доставки), sent и failed (эти два хранятся 7 дней).
  • PATCH /webhooks/{uuid} — любое из url, events, enabled; ответ {success, webhook}. Ни одного из трёх полей — 422. Смена url обнуляет счётчик отказов и снимает авто-паузу (но не ручное выключение); enabled: true включает вебхук и обнуляет счётчик.
  • DELETE /webhooks/{uuid} → {success, deleted: "<webhook_id>"}; недоставленные события удаляются вместе с ним.
  • POST /webhooks/{uuid}/rotate_secret → {success, webhook, secret, note}, тело запроса не нужно. Старый секрет перестаёт действовать сразу, в том числе для событий, которые уже ждут повторной доставки.
  • POST /webhooks/{uuid} → 405: для правки — PATCH.

Секрет показывается один раз — при создании и при rotate_secret; дальше в ответах только маска secret_hint (первые 8 символов). Потеряли — перевыпустите.

Поля объекта webhook

ПолеТипОбяз.Описание
webhook_idstring—UUID вебхука.
urlstring—Нормализованный адрес приёмника.
eventsarray—Типы событий. При подписке «на все» — полный список.
statusstring—active или paused.
paused_reasonstring—Почему на паузе: «создан выключенным», «выключен вручную», «5 неудачных доставок подряд». У активного — пустая строка.
secret_hintstring—Маска секрета: первые 8 символов и восемь точек.
fail_countint—Неудачных доставок подряд; успешная обнуляет.
last_errorstring—Последняя ошибка доставки, например HTTP 500, «connection error: …», «dns: host does not resolve». Тело ответа вашего приёмника сюда не попадает.
last_delivery_atstring—Последняя успешная доставка; null, если её ещё не было.
created_atstring—Дата создания.

created_at и last_delivery_at здесь — в формате MySQL YYYY-MM-DD HH:MM:SS по московскому времени, без смещения, а не ISO 8601, как в событиях.

Создание
curl -X POST https://api.mailermail.ru/v1/webhooks \
  -H "Api-Token: mm_live_9f3k…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.ru/hooks/mail",
    "events": ["email.bounced", "email.opened"]
  }'
$ch = curl_init('https://api.mailermail.ru/v1/webhooks');
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'Api-Token: mm_live_9f3k…',
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'url' => 'https://shop.ru/hooks/mail',
    'events' => ['email.bounced', 'email.opened'],
  ]),
  CURLOPT_RETURNTRANSFER => true,
]);
$res = json_decode(curl_exec($ch), true);
$id     = $res['webhook']['webhook_id'];
$secret = $res['secret']; // сохраните, больше не покажем
Ответ201 POST /webhooks
{
  "success": true,
  "webhook": {
    "webhook_id": "7f1c2d3e-4b5a-4c6d-9e8f-0a1b2c3d4e5f",
    "url": "https://shop.ru/hooks/mail",
    "events": ["email.bounced", "email.opened"],
    "status": "active",
    "paused_reason": "",
    "secret_hint": "3f9a1c2e••••••••",
    "fail_count": 0,
    "last_error": "",
    "last_delivery_at": null,
    "created_at": "2026-09-18 12:00:00"
  },
  "secret": "3f9a1c2e5b7d9f0a1c3e5b7d9f0a2c4e6b8d0f1a3c5e7b9d1f3a5c7e9b0d2f4a",
  "note": "Store the secret now — it is shown only once. Signature: MailerMail-Signature = hex(HMAC-SHA256(raw body, secret))."
}
Ответ200 GET /webhooks
{
  "success": true,
  "total": 1,
  "webhooks": [
    {
      "webhook_id": "7f1c2d3e-4b5a-4c6d-9e8f-0a1b2c3d4e5f",
      "url": "https://shop.ru/hooks/mail",
      "events": ["email.bounced", "email.opened"],
      "status": "paused",
      "paused_reason": "5 неудачных доставок подряд",
      "secret_hint": "3f9a1c2e••••••••",
      "fail_count": 5,
      "last_error": "HTTP 502",
      "last_delivery_at": "2026-09-18 12:04:01",
      "created_at": "2026-09-18 12:00:00"
    }
  ]
}
Ответ200 GET /webhooks/{uuid}
{
  "success": true,
  "webhook": { "webhook_id": "7f1c2d3e-…", "…": "…" },
  "queue": {"pending": 12, "sent": 3480, "failed": 0}
}
Включить после починки
PATCH /v1/webhooks/7f1c2d3e-…

{"enabled": true}
Ответ200 PATCH
{
  "success": true,
  "webhook": {
    "webhook_id": "7f1c2d3e-4b5a-4c6d-9e8f-0a1b2c3d4e5f",
    "status": "active",
    "paused_reason": "",
    "fail_count": 0,
    "…": "…"
  }
}
Ответ200 DELETE
{
  "success": true,
  "deleted": "7f1c2d3e-4b5a-4c6d-9e8f-0a1b2c3d4e5f"
}
Ответ200 POST …/rotate_secret
{
  "success": true,
  "webhook": { "webhook_id": "7f1c2d3e-…", "secret_hint": "b41e07d9••••••••", "…": "…" },
  "secret": "b41e07d9…(64 hex-символа)",
  "note": "Store the secret now — it is shown only once."
}

Доставка и подпись

Каждая доставка — POST с JSON-телом {"events": [ … ]}, в пачке от 1 до 500 событий одного вебхука.

Заголовки запроса

ПолеТипОбяз.Описание
Content-Typeheader—application/json
MailerMail-Signatureheader—Подпись тела: 64 символа, hex в нижнем регистре.
MailerMail-Deliveryheader—UUID попытки: при повторе той же пачки он новый. Для дедупликации не годится.
MailerMail-Event-Countheader—Число событий в пачке.
User-Agentheader—MailerMail-Webhooks/1.0 и ссылка на описание Email API в скобках.

Проверка подписи

MailerMail-Signature = HMAC-SHA256 от сырого тела запроса, в hex нижним регистром. Ключ — строка секрета как есть, все 64 символа: не декодируйте её из hex в байты. Префикса вроде sha256= нет, метки времени в подписи нет.

  • Считайте подпись по байтам тела до разбора JSON: пересобранный JSON отличается пробелами и экранированием, и подпись не сойдётся.
  • Сравнивайте функцией постоянного времени (hash_equals, crypto.timingSafeEqual).
  • Защиты от повторной отправки перехваченного запроса подпись не даёт — отсекайте повторы по event_id.

Ответ приёмника и повторы

  • Успех — любой 2xx. Тело ответа игнорируется. Редиректы (3xx) не выполняются и считаются неудачей.
  • На соединение даётся 5 секунд, на весь ответ — 15. Отвечайте сразу, обработку делайте после ответа.
  • Неудачная пачка повторяется через 1, 4, 8, 16 и 32 минуты; у события — около 6 попыток, после чего оно помечается недоставленным. Счётчик попыток пачки берётся по самому свежему событию в ней, поэтому старое событие, попавшее в пачку с новыми, может получить больше попыток и повтор раньше расписания.
  • При повторе пачка может собраться иначе: к старым событиям добавятся новые, а часть может уйти в другой пачке. Не полагайтесь ни на состав пачки, ни на MailerMail-Delivery.
  • Дедупликация — по event_id: одно событие может прийти дважды, если ответ вашего приёмника до нас не дошёл.
  • 5 неудачных доставок подряд ставят вебхук на паузу (paused_reason: "5 неудачных доставок подряд"). Считаются доставки, а не события: пока события идут потоком, попытка случается каждую минуту, и недоступный приёмник уходит на паузу примерно через 5 минут; одиночное событие — примерно через полчаса. Включить обратно — PATCH {"enabled": true}, смена url или кнопка в кабинете.

Пока вебхук на паузе, новые события в его очередь не попадают — восстановить их потом нельзя. События, уже стоявшие в очереди, ждут включения, но не больше суток с момента самого события (а не паузы); после этого они помечаются недоставленными. История событий письма всегда доступна через GET /messages/{uuid}/events (30 дней) независимо от подписок.

Запрос к приёмнику
POST /hooks/mail HTTP/1.1
Host: shop.ru
Content-Type: application/json
MailerMail-Signature: 5f0c3b7e0d1a…(64 hex)
MailerMail-Delivery: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
MailerMail-Event-Count: 1
User-Agent: MailerMail-Webhooks/1.0 (+https://mailermail.ru/email-api/)

{"events":[{"event_id":"3b9c1d2e-…","event":"email.sent","message_id":"0b6f7a52-…","email":"ivan@example.ru","timestamp":"2026-09-18T12:00:02+03:00","category":"orders","data":{"custom_variables":{"order_id":"4211"}}}]}
Проверка подписи
// приёмник на вашей стороне
$SECRET = getenv('MM_WEBHOOK_SECRET'); // 64 символа как есть
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_MAILERMAIL_SIGNATURE'] ?? '';

$calc = hash_hmac('sha256', $raw, $SECRET);
if (!hash_equals($calc, $sig)) {
    http_response_code(403);
    exit;
}
http_response_code(200);   // подтверждаем СРАЗУ
fastcgi_finish_request();  // обработка — после ответа

foreach (json_decode($raw, true)['events'] as $e) {
    if (already_seen($e['event_id'])) continue; // дедупликация
    // $e['event'], $e['message_id'], $e['email'],
    // $e['data']['custom_variables'] ?? []
}
// Express: тело нужно сырым, без express.json()
const crypto = require('crypto');
const SECRET = process.env.MM_WEBHOOK_SECRET; // 64 символа как есть

app.post('/hooks/mail',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const sig  = req.get('MailerMail-Signature') || '';
    const calc = crypto.createHmac('sha256', SECRET)
                       .update(req.body)          // Buffer
                       .digest('hex');
    const ok = sig.length === calc.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(calc));
    if (!ok) return res.sendStatus(403);

    res.sendStatus(200);                          // подтверждаем сразу
    const { events } = JSON.parse(req.body.toString('utf8'));
    for (const e of events) {
      // дедупликация по e.event_id, дальше — ваша логика
    }
  });

Типы событий

Поля события

ПолеТипОбяз.Описание
event_idstring—UUID события. Ключ дедупликации.
eventstring—Тип события, см. таблицу ниже.
message_idstring|null—UUID письма. Бывает null у email.unsubscribed.
emailstring—Адрес получателя, в нижнем регистре.
timestampstring—Когда событие записано у нас (для email.bounced — время разбора журналов, а не самого отказа): ISO 8601 со смещением сервера, 2026-09-18T12:04:11+03:00.
categorystring—Категория письма. Ключа нет, если категория пустая.
dataobject—Подробности по типу события. Ключа нет, если подробностей нет.

data по типам

СобытиеКогда и что в data
email.sentSMTP-реле MailerMail приняло письмо к доставке (это не «доставлено в ящик» — такого события нет). У /batch — в момент фактической отправки воркером, а не постановки в очередь. data — только custom_variables; без них ключа data нет.
email.failedОтправить не удалось. reason — текст, как в failed[] ответа /send; у /batch ещё enqueue failed и sender domain no longer verified (домен перестал быть подтверждённым, пока письмо ждало в очереди). Плюс custom_variables?.
email.suppressedАдрес пропущен, письмо не отправлялось. reason: suppressed или known_bounce. Плюс custom_variables?.
email.openedЗагрузили пиксель открытия. ip, user_agent, client (подпись почтового клиента для человека: Gmail, Яндекс.Почта, Apple Mail (iOS), Другое — не перечисление, список пополняется), country?, city?, custom_variables?. Открытия чаще всего приходят через прокси почтовика (Gmail, Яндекс), поэтому IP и гео — прокси, а не человека.
email.clickedПереход по обёрнутой ссылке. То же, что у открытия, плюс url — исходный адрес ссылки.
email.unsubscribedОтписка по One-Click-ссылке MailerMail. method: "one-click"; attribution: "by-address", если удалось подобрать письмо. custom_variables нет.
email.bouncedЖёсткий отказ. bounce_type, smtp_code?, attribution: "by-address". custom_variables нет. См. ограничения ниже.
email.complainedЖалоба на спам. Сейчас не отправляется, см. ниже.

bounce_type

HBHard bounce: ящика или домена нет, ящик закрыт, либо сервер отклонил письмо адресно (в том числе по своей политике: «Access denied», «Sender verify failed»). В стоп-лист с причиной hard_bounce попадают только ответы вида «такого ящика нет».
MBSDОтклонено как спам: блоклист (DNSBL), контентный фильтр, плохая репутация отправителя. Адрес при этом может быть живым — в стоп-лист не попадает.
MBKS«Known SPAM source»: сервер получателя счёл IP отправителя известным источником спама. Тоже про репутацию, а не про адрес; в стоп-лист не попадает.

smtp_code — трёхзначный код ответа сервера строкой ("550", не расширенный "5.1.1"); если кода в ответе не нашлось, ключа нет. Текста ответа почтового сервера в событии нет намеренно: в нём имена наших серверов и внутренние идентификаторы.

message_id в событиях

  • email.sent, email.opened, email.clicked — тот же message_id, что вернул /send или /batch. У /batch так же и email.failed, если письмо не ушло из очереди.
  • email.suppressed и email.failed по адресам из skipped[]/failed[] несут новый UUID, которого в ответе не было: сопоставляйте их по email и custom_variables.
  • email.bounced и email.unsubscribed приходят не из письма, а из внешних источников, поэтому письмо подбирается по адресу: последнее успешно отправленное через API на этот адрес за 7 дней (отказ) или за 30 дней (отписка). Отсюда attribution: "by-address": это догадка, а не факт. Для отписки письма может не найтись — тогда message_id: null, а category нет.

Ограничения, о которых важно знать

  • email.bounced для писем Email API сейчас почти не приходит. Отказы разбираются по журналам рассылок кабинета, и только для контактов их списков. Событие появится, лишь если адрес, получивший ваше письмо через API за последние 7 дней, отказал в рассылке из кабинета того же аккаунта. Отказ самого API-письма события не порождает.
  • email.complained сейчас не отправляется никогда. Жалобы через FBL обрабатываются: адрес попадает в стоп-лист с причиной complained и дальше пропускается. Следить за жалобами можно через GET /suppressions.
  • Песочница (mm_test_) событий не порождает вовсе.

История событий одного письма — GET /messages/{uuid}/events, до 500 событий за 30 дней. Поля там называются иначе: type вместо event и created_at вместо timestamp, а пустой data приходит как {}.

Открытие и переход
{
  "events": [
    {
      "event_id": "3b9c1d2e-5f60-4a71-8b92-c3d4e5f60718",
      "event": "email.opened",
      "message_id": "0b6f7a52-3c1e-4d8a-9f2b-6e1d0c4a7b93",
      "email": "ivan@example.ru",
      "timestamp": "2026-09-18T12:04:11+03:00",
      "category": "orders",
      "data": {
        "ip": "66.249.84.12",
        "user_agent": "Mozilla/5.0 … GoogleImageProxy",
        "client": "Gmail",
        "country": "US",
        "custom_variables": {"order_id": "4211"}
      }
    },
    {
      "event_id": "a0b1c2d3-e4f5-4061-8273-94a5b6c7d8e9",
      "event": "email.clicked",
      "message_id": "0b6f7a52-3c1e-4d8a-9f2b-6e1d0c4a7b93",
      "email": "ivan@example.ru",
      "timestamp": "2026-09-18T12:05:40+03:00",
      "category": "orders",
      "data": {
        "ip": "95.108.213.7",
        "user_agent": "Mozilla/5.0 (iPhone; …)",
        "url": "https://shop.ru/orders/4211",
        "client": "Apple Mail (iOS)",
        "country": "RU",
        "city": "Москва",
        "custom_variables": {"order_id": "4211"}
      }
    }
  ]
}
data других событий
{"event": "email.sent", "…": "…",
 "data": {"custom_variables": {"order_id": "4211"}}}

{"event": "email.failed", "…": "…",
 "data": {"reason": "SMTP Error: data not accepted.",
          "custom_variables": {"order_id": "4211"}}}

{"event": "email.suppressed", "…": "…",
 "data": {"reason": "known_bounce"}}

{"event": "email.unsubscribed", "message_id": null, "…": "…",
 "data": {"method": "one-click"}}

{"event": "email.bounced", "…": "…",
 "data": {"bounce_type": "HB", "attribution": "by-address",
          "smtp_code": "550"}}
История событий письма
curl "https://api.mailermail.ru/v1/messages/0b6f7a52-…/events" \
  -H "Api-Token: mm_live_9f3k…"
Ответ200
{
  "success": true,
  "message_id": "0b6f7a52-3c1e-4d8a-9f2b-6e1d0c4a7b93",
  "total": 1,
  "events": [
    {
      "event_id": "3b9c1d2e-5f60-4a71-8b92-c3d4e5f60718",
      "type": "email.sent",
      "email": "ivan@example.ru",
      "data": {},
      "created_at": "2026-09-18T12:00:02+03:00"
    }
  ]
}

Трекинг открытий и переходов

В HTML-письмо можно вшить пиксель открытия и обернуть ссылки редиректом — тогда приходят события email.opened и email.clicked. Управляют этим два флага в options:

ПолеТипОбяз.Описание
options.track_readboolнетПиксель открытия. В /batch включён по умолчанию, в /send — выключен.
options.track_linksboolнетОборачивать ссылки. Значения по умолчанию те же.

Транзакционному письму трекинг обычно не нужен — код подтверждения и счёт незачем считать пикселем, — поэтому в /send он выключен. В массовой рассылке наоборот: без статистики открытий она бессмысленна.

Трекинг работает только в HTML-версии: ссылки в текстовой версии не оборачиваются, а письмо без html остаётся без пикселя. Пиксель ставится перед </body>, а если его нет — в конец HTML. Обёрнутая ссылка ведёт на https://mailermail.ru/api/v1/t/c/… и отвечает редиректом 302 на исходный адрес; подпись в ссылке покрывает и сам адрес, подменить его нельзя.

Какие ссылки не оборачиваются

  • с атрибутом data-mm-no-track у тега <a>;
  • не http:// и не https://: mailto:, tel:, якоря #…, относительные адреса;
  • с {{ в адресе — неподставленная переменная (так бывает, когда переменные не переданы вовсе);
  • служебные ссылки MailerMail — отписка https://mailermail.ru/api/v1/u/… и уже обёрнутые https://mailermail.ru/api/v1/t/…;
  • адрес длиннее 1500 символов;
  • все ссылки письма, если HTML больше 512 КБ, — пиксель при этом всё равно ставится;
  • теги <a> без href.

Известная проблема: адрес с пробелом, ", < или > внутри (например, неэкранированный пробел в параметре) оборачивается, но по клику отвечает 400 — получатель увидит битую ссылку. Кодируйте такие символы в адресах (%20) или ставьте на ссылку data-mm-no-track.

Дедупликация и сроки

Открытия и переходы дедуплицируются на нашей стороне: повторное открытие того же письма в течение 10 минут и повторный переход в течение 1 минуты событий не порождают. Окно считается на письмо, а не на ссылку: второй переход по другой ссылке в ту же минуту тоже не придёт. На одно письмо приходится не более 200 событий — предзагрузка картинок почтовиком и антивирусные сканеры не превращаются в поток дублей.

События трекинга приходят, пока письмо хранится в логе, — 30 дней после отправки. Письма песочницы событий не порождают.

Письмо с трекингом
curl -X POST https://api.mailermail.ru/v1/send \
  -H "Api-Token: mm_live_9f3k…" \
  -d '{
    "from": {"email": "news@shop.ru"},
    "to": [{"email": "ivan@example.ru"}],
    "subject": "Новинки недели",
    "html": "<a href=\"https://shop.ru/new\">Смотреть</a> <a href=\"https://shop.ru/help\" data-mm-no-track>Помощь</a>",
    "options": {"track_read": true, "track_links": true}
  }'

Переезд с UniOne

Модель та же — JSON поверх HTTPS, переменные {{var}}, вебхуки о событиях писем, — но в нескольких местах поведение отличается так, что ошибка не видна сразу. Проверьте эти пункты до переключения.

ЧтоВ MailerMail
метаданные письмаcustom_variables — одно значение на весь запрос, не на получателя; значения приводятся к строке. Поле metadata игнорируется молча, как любое неизвестное поле, и в событиях ничего не будет.
неизвестная переменнаяСтановится пустой строкой, а не остаётся в письме как есть. Опечатку в имени переменной в тестовом письме не видно.
ссылка отписки в телеАвтоматически не вставляется, служебного {{unsubscribe_url}} нет. Отписка — только заголовками (options.list_unsubscribe или свой unsubscribe_url), ссылку в теле ставьте сами.
трекинг в /sendОткрытия и переходы по умолчанию выключены; включаются options.track_read и options.track_links.
события о жалобахemail.complained сейчас не приходит; жалобы видны в стоп-листе с причиной complained.
события об отказахemail.bounced для писем Email API сейчас почти не приходит (см. «Типы событий»). События «доставлено в ящик» нет: email.sent означает, что письмо принял наш SMTP-сервер.
лимит запросов120 запросов в минуту на токен — примерно в 50 раз меньше привычных ~100 в секунду. Отправляйте до 50 получателей в одном /send или пачками через /batch, а не по запросу на письмо.
отложенная отправкаТолько в /batch (options.send_at, время в UTC, не дальше 72 часов); в /send — 422.
подпись вебхукаHMAC-SHA256 от тела в заголовке MailerMail-Signature (см. «Доставка и подпись»).
Типичный запрос после переезда
{
  "from": {"email": "robot@school.ru", "name": "Школа"},
  "to": [
    {"email": "student@example.ru",
     "substitutions": {"name": "Мария"}}
  ],
  "template_id": "lesson-reminder",
  "template_variables": {"lesson": "Урок 3"},
  "custom_variables": {"user_id": "77", "lesson_id": "3"},
  "options": {
    "list_unsubscribe": true,
    "track_read": true,
    "track_links": true
  }
}