MAILCORE ДЛЯ РАЗРАБОТЧИКОВ
Email API — документация для разработчиков
Подключение Mailcore Email API, отправка транзакционных писем, идемпотентность, статусы доставки и проверка вебхуков.
Что делает Email API
Отправляйте ожидаемые сервисные письма из вашего приложения: подтверждения заказов, чеки, восстановление пароля, уведомления о входе и бронировании.
Mailcore принимает сообщение в устойчивую очередь, затем передаёт его Amazon SES и обновляет состояние по событиям провайдера. Ответ 202 подтверждает сохранение задания, а не доставку адресату. Сохраняйте идентификатор письма и отслеживайте итог через GET-запрос или подписанные вебхуки.
| Продукт | Назначение | Подключение |
|---|---|---|
| Корпоративная почта | Ящики сотрудников, входящая почта, веб-почта и переписка людей | Ящики, оплата и настройка почтового домена |
| Email API | Сервисные сообщения, которые отправляет ваше приложение | Проект, отправляющий домен, разрешённый отправитель и API-ключ после проверки |
Email API предназначен для транзакционных сообщений с понятной связью между получателем и вашим сервисом. Холодные рассылки, купленные базы, массовый маркетинг и произвольный почтовый relay в этот контракт не входят. Не обещайте пользователю попадание во входящие или определённое время доставки: статус доставки не подтверждает прочтение.
https://mailcore.uz/api/email/v1
POST /emails
GET /emails/{id}
GET /healthzПодключение и домен
Сначала подтвердите владение доменом и согласуйте сценарий использования. Ключ и право отправлять появляются только после отдельной активации.
- Откройте /onboarding/api/, войдите в аккаунт, создайте или выберите организацию. Укажите проект, типы сервисных писем, ожидаемый месячный объём, отправляющий домен и локальную часть адреса отправителя.
- Опубликуйте именно тот TXT-код владения, который показан вашей заявке. Mailcore проверяет его на авторитетных DNS-серверах. Этот код подтверждает контроль над доменом, но не готовность к отправке.
- После подтверждения примите правила транзакционных сообщений и отправьте заявку. Состояние review_pending означает ожидание проверки, а не выданный доступ.
- Во время сопровождаемого подключения оператор согласует лимиты, проверит домен и инфраструктуру отправки, привяжет отправителя и передаст одноразовый секрет ключа через согласованный защищённый канал.
Какие DNS-записи нужны
Используйте DNS-инструкции для вашего конкретного домена, полученные при сопровождаемой настройке. TXT владения Mailcore и записи проверки SES/DKIM решают разные задачи. Текущая форма заявки не возвращает готовые SES, DKIM, SPF, DMARC или custom MAIL FROM записи. Универсальные значения из чужого примера не подходят.
Для подключения API не нужно переносить входящую почту или менять MX существующих ящиков. Можно обсудить отдельный отправляющий поддомен, например notify.example.uz, чтобы отделить репутацию сервисных писем. Любые изменения DNS, включая возможный MAIL FROM, выполняйте только по инструкции для вашего подключения. Не создавайте почтовые ящики ради обхода отсутствующей настройки API-домена.
Авторизация и хранение ключа
Все методы отправки и чтения состояния используют Bearer-ключ вашего проекта.
Authorization: Bearer <MAILCORE_API_KEY>
Content-Type: application/json
Idempotency-Key: order-8421-completedВ примерах MAILCORE_API_KEY — переменная окружения на вашем сервере, а не опубликованный действующий ключ. Секрет показывают один раз при создании. Храните его в менеджере секретов или защищённой серверной конфигурации. Не помещайте ключ в URL, репозиторий, фронтенд, мобильное приложение, localStorage, аналитику или журналы запросов.
Ключ связан с проектом и действует только в его границах. Он не является учётной записью администратора и не управляет заявками или биллингом. Сессионная cookie сайта, код входа в аккаунт и пароль почтового ящика не авторизуют /api/email/v1/*. Для разных приложений или окружений согласуйте отдельные проекты и ключи.
Отсутствующий, неверный, истёкший или отозванный ключ возвращает 401 authentication_required. При остановленном проекте или организации авторизация также обычно возвращает 401; изменение разрешений уже во время запроса может дать 403 account_paused. Повторение без исправления доступа не поможет.
Первое письмо
Выполните пример на сервере после подтверждённой активации. Подставьте свой разрешённый отправитель и проверенный адрес получателя.
Подготовьте MAILCORE_API_KEY в окружении процесса. Для одного события бизнеса создайте устойчивый Idempotency-Key длиной от 16 символов, сохраните его вместе с данными операции и задайте как MAILCORE_IDEMPOTENCY_KEY. Пример использует одну строку to и текстовую версию письма; при необходимости можно дополнительно передать html.
#!/usr/bin/env sh
set -eu
# Run only after operator approval. Replace the .example addresses below with
# your approved sender and intended recipient. Keep the key on your server.
# Sending is disabled at this docs release; production activation is separate.
# Save MAILCORE_IDEMPOTENCY_KEY with this order/event before the first attempt;
# reuse it and the unchanged payload after an uncertain HTTP response.
# This example performs one attempt. A queued/accepted result is not delivery;
# never blindly send an ambiguous message again under a new key.
: "${MAILCORE_API_KEY:?Set the operator-issued server key}"
: "${MAILCORE_IDEMPOTENCY_KEY:?Set the key persisted with this application event}"
case "$MAILCORE_IDEMPOTENCY_KEY" in
*[!A-Za-z0-9._:-]*) printf '%s\n' 'Invalid idempotency key characters' >&2; exit 1 ;;
esac
if [ "${#MAILCORE_IDEMPOTENCY_KEY}" -lt 16 ] || [ "${#MAILCORE_IDEMPOTENCY_KEY}" -gt 128 ]; then
printf '%s\n' 'Idempotency key must contain 16–128 characters' >&2
exit 1
fi
curl --silent --show-error --fail-with-body \
--connect-timeout 10 --max-time 30 \
https://mailcore.uz/api/email/v1/emails \
-H "Authorization: Bearer ${MAILCORE_API_KEY}" \
-H "Idempotency-Key: ${MAILCORE_IDEMPOTENCY_KEY}" \
-H 'Content-Type: application/json' \
--data-binary '{
"from": "Example Shop <orders@notify.example>",
"to": "customer@recipient.example",
"subject": "Order #8421 completed",
"text": "Your order has been completed.",
"tags": {"order_id": "8421"}
}'
{
"id": "eml_0123456789abcdef01234567",
"status": "queued"
}- Сохраните id рядом с заказом или другой операцией: он понадобится для поддержки, чтения состояния и связи с вебхуками.
- Если ответ потерян или произошёл таймаут, повторите исходный запрос с тем же ключом идемпотентности. Не создавайте новый ключ для той же попытки.
- Проверьте состояние через GET /emails/{id}. Для регулярного получения событий настройте вебхуки; не делайте частый опрос без необходимости.
Справочник POST /emails
Метод создаёт одно письмо для одного получателя. Тело запроса — JSON; неизвестные поля отклоняются.
| Поле | Тип и обязательность | Правила |
|---|---|---|
| from | Строка, обязательно | 3–320 символов. Адрес активного отправителя в вашем проекте на проверенном домене. |
| to | Строка, обязательно | 3–320 символов; ровно один корректный адрес. Массив и список адресов не поддерживаются. |
| subject | Строка, обязательно | 1–998 символов; без CR, LF, NUL и других управляющих символов, кроме табуляции. |
| text | Строка или null, необязательно | Текстовая версия. Нужен хотя бы один из text/html; суммарно не более 200 000 байт UTF-8. |
| html | Строка или null, необязательно | HTML-версия. Ограничение 200 000 байт общее с text; учитывайте многобайтовые символы. |
| replyTo | Строка или null, необязательно | Один корректный адрес, 3–320 символов. Управляет адресом для ответа, а не разрешением отправителя. |
| tags | Объект строковых значений, необязательно | До 10 пар. Имя: 1–64 символа, значение: 1–256. Разрешены только A–Z, a–z, 0–9, _ и -. Имена с префиксом mailcore_ в любом регистре зарезервированы. |
Адреса могут включать отображаемое имя длиной до 128 символов, например Example Shop <orders@shop.example>. Для from имя, если оно указано, должно точно совпадать с привязкой отправителя. Если отправить только разрешённый адрес без имени, Mailcore использует имя из привязки. Произвольная замена имени или отправляющего адреса возвращает sender_not_allowed.
Передавайте содержательное непустое тело письма. Если используете HTML, добавьте text для клиентов, которые не отображают HTML. Не включайте пользовательский ввод без экранирования. Полный HTTP-body ограничен 256 KiB, включая JSON-обёртку и chunked-передачу; это отдельное ограничение от размера text + html.
Примеры Node.js, Python и PHP
Это примеры HTTP-интеграции, а не отдельные SDK Mailcore. Все используют один и тот же REST-контракт.
Замените демонстрационные адреса и событие заказа своими данными. Вынесите генерацию ключа идемпотентности и сохранение id в бизнес-логику приложения. Обрабатывайте HTTP-статус до того, как считать операцию успешной; сетевое исключение не доказывает, что запрос не был сохранён.
| Пример | Требования |
|---|---|
| Node.js | Node.js 18+; встроенный fetch, без дополнительных пакетов |
| Python | Python 3.10+; стандартная библиотека |
| PHP | PHP 8.1+ с поддержкой HTTPS streams; без Composer |
Скрипты используют MAILCORE_API_KEY и сохранённый MAILCORE_IDEMPOTENCY_KEY. В Node.js, Python и PHP задайте MAILCORE_FROM и MAILCORE_TO для своих адресов; в cURL измените JSON примера перед запуском. MAILCORE_FROM должен соответствовать привязке отправителя, а не адресу аккаунта на сайте.
// Node.js 18+, no packages. Run after approval with server environment secrets.
// Sending is disabled at this docs release; production activation is separate.
// Persist the idempotency key WITH the business event before the first attempt.
// This makes one request; on an uncertain HTTP result reuse the unchanged body
// and key. Never resend an ambiguous message under a fresh key.
const apiKey = process.env.MAILCORE_API_KEY;
const idempotencyKey = process.env.MAILCORE_IDEMPOTENCY_KEY;
if (!apiKey || /[\r\n]/.test(apiKey)) throw new Error('Set MAILCORE_API_KEY on your server');
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(idempotencyKey ?? '')) {
throw new Error('Set the 16–128 character key saved with this application event');
}
const request = {
from: process.env.MAILCORE_FROM ?? 'Example Shop <orders@notify.example>',
to: process.env.MAILCORE_TO ?? 'customer@recipient.example',
subject: 'Order #8421 completed',
text: 'Your order has been completed.',
tags: { order_id: '8421' },
};
if (request.from.includes('.example') || request.to.endsWith('.example')) {
throw new Error('Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the intended recipient');
}
try {
const response = await fetch('https://mailcore.uz/api/email/v1/emails', {
method: 'POST',
redirect: 'error',
headers: {
Authorization: `Bearer ${apiKey}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(30_000),
});
const result = await response.json();
if (response.status !== 202) {
console.error(JSON.stringify({ httpStatus: response.status, ...result }));
process.exitCode = 1;
} else {
// Persist result.id on the same business event; poll GET /emails/{id}.
console.log(JSON.stringify(result));
}
} catch {
// Do not log request bodies or keys. Acceptance may be uncertain after I/O.
console.error('No usable HTTP result. Reconcile using the saved event key and unchanged payload.');
process.exitCode = 1;
}
"""Python 3.10+, standard library. One attempt, only after project approval.
Sending is disabled at this docs release; production activation is separate.
Persist MAILCORE_IDEMPOTENCY_KEY with the application event BEFORE sending.
Reuse that key and unchanged payload after an uncertain HTTP result. Save the
returned id with the event. Never blindly resend an ambiguous message.
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # Never forward a Bearer key to a redirect destination.
def main():
api_key = os.environ.get("MAILCORE_API_KEY", "")
key = os.environ.get("MAILCORE_IDEMPOTENCY_KEY", "")
if not api_key or "\r" in api_key or "\n" in api_key:
raise SystemExit("Set MAILCORE_API_KEY in your server environment")
if not re.fullmatch(r"[A-Za-z0-9._:-]{16,128}", key):
raise SystemExit("Set the idempotency key saved with this application event")
payload = {
"from": os.environ.get("MAILCORE_FROM", "Example Shop <orders@notify.example>"),
"to": os.environ.get("MAILCORE_TO", "customer@recipient.example"),
"subject": "Order #8421 completed",
"text": "Your order has been completed.",
"tags": {"order_id": "8421"},
}
if ".example" in payload["from"] or payload["to"].endswith(".example"):
raise SystemExit("Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the recipient")
request = urllib.request.Request(
"https://mailcore.uz/api/email/v1/emails",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + api_key,
"Idempotency-Key": key,
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
result = json.loads(response.read(1_048_577))
if response.status != 202:
raise ValueError("unexpected HTTP status")
print(json.dumps(result)) # Persist id on the business event.
except urllib.error.HTTPError as error:
try:
detail = json.loads(error.read(1_048_577))
except (ValueError, OSError):
detail = {"error": "unexpected_http_response"}
print(json.dumps({"httpStatus": error.code, **detail}), file=sys.stderr)
raise SystemExit(1)
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
raise SystemExit("No usable HTTP result. Reconcile with the saved event key and unchanged payload.")
if __name__ == "__main__":
main()
<?php
// PHP 8.1+ with HTTPS stream support. No Composer packages.
// Sending is disabled at this docs release; production activation is separate.
// Persist this idempotency key with the application event before sending.
// Reuse key + unchanged payload after an uncertain HTTP result. Save the
// returned id on the same event. Never resend an ambiguous message blindly.
declare(strict_types=1);
$apiKey = getenv('MAILCORE_API_KEY') ?: '';
$key = getenv('MAILCORE_IDEMPOTENCY_KEY') ?: '';
if ($apiKey === '' || preg_match('/[\r\n]/', $apiKey)) {
fwrite(STDERR, "Set MAILCORE_API_KEY on your server\n");
exit(1);
}
if (!preg_match('/^[A-Za-z0-9._:-]{16,128}$/D', $key)) {
fwrite(STDERR, "Set the idempotency key saved with this application event\n");
exit(1);
}
$payload = [
'from' => getenv('MAILCORE_FROM') ?: 'Example Shop <orders@notify.example>',
'to' => getenv('MAILCORE_TO') ?: 'customer@recipient.example',
'subject' => 'Order #8421 completed',
'text' => 'Your order has been completed.',
'tags' => ['order_id' => '8421'],
];
if (str_contains($payload['from'], '.example') || str_ends_with($payload['to'], '.example')) {
fwrite(STDERR, "Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the recipient\n");
exit(1);
}
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer {$apiKey}\r\nIdempotency-Key: {$key}\r\nContent-Type: application/json\r\n",
'content' => json_encode($payload, JSON_THROW_ON_ERROR),
'timeout' => 30,
'ignore_errors' => true,
'follow_location' => 0, // Do not forward the Bearer key on redirects.
], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$result = @file_get_contents('https://mailcore.uz/api/email/v1/emails', false, $context, 0, 1048577);
if ($result === false) {
fwrite(STDERR, "No usable HTTP result. Reconcile with the saved event key and unchanged payload.\n");
exit(1);
}
$status = 0;
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+ (\d{3})/', $header, $match)) $status = (int) $match[1];
}
try {
$document = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($document)) throw new RuntimeException('Unexpected response');
if ($status !== 202) {
fwrite(STDERR, json_encode(['httpStatus' => $status] + $document, JSON_THROW_ON_ERROR) . "\n");
exit(1);
}
echo json_encode($document, JSON_THROW_ON_ERROR) . "\n";
} catch (Throwable $error) {
fwrite(STDERR, "No usable JSON result. Reconcile with the saved event key and unchanged payload.\n");
exit(1);
}
В production используйте ограниченные таймауты, очередь вашего приложения и контролируемые повторные попытки. Не отправляйте письма синхронно из браузера и не записывайте секреты или полный payload в сообщения об ошибках. Повтор с новым Idempotency-Key — новое письмо, даже если данные совпадают.
Чтение и статусы письма
GET /emails/{id} возвращает текущее состояние письма внутри проекта, которому принадлежит ключ.
#!/usr/bin/env sh
set -eu
# Use the id returned by POST /emails. No recipient or body is returned.
# accepted means provider handoff. Do not resend an ambiguous message blindly.
: "${MAILCORE_API_KEY:?Set the operator-issued server key}"
: "${MAILCORE_EMAIL_ID:?Set the returned eml_ message ID}"
case "$MAILCORE_EMAIL_ID" in
eml_*) mailcore_id_hex="${MAILCORE_EMAIL_ID#eml_}" ;;
*) printf '%s\n' 'MAILCORE_EMAIL_ID must be a returned eml_ message ID' >&2; exit 1 ;;
esac
case "$mailcore_id_hex" in
*[!a-f0-9]*) printf '%s\n' 'Invalid message ID' >&2; exit 1 ;;
esac
if [ "${#mailcore_id_hex}" -ne 24 ]; then
printf '%s\n' 'Message ID must contain eml_ followed by 24 lowercase hex digits' >&2
exit 1
fi
curl --silent --show-error --fail-with-body \
--connect-timeout 10 --max-time 30 \
"https://mailcore.uz/api/email/v1/emails/${MAILCORE_EMAIL_ID}" \
-H "Authorization: Bearer ${MAILCORE_API_KEY}"
{
"id": "eml_0123456789abcdef01234567",
"status": "delivered",
"from": "Example Shop <orders@shop.example>",
"providerMessageId": "example-provider-message-id",
"detail": "ses:delivery",
"createdAt": "2026-10-14T08:00:00.000Z",
"updatedAt": "2026-10-14T08:00:02.000Z"
}| Статус | Значение | Действие приложения |
|---|---|---|
| queued | Запрос сохранён и ожидает обработки | Сохранить id и ждать состояния; не создавать копию |
| sending | Обработчик начал передачу провайдеру | Ждать результата |
| accepted | SES принял письмо и вернул идентификатор | Ждать события доставки; это ещё не доставка |
| delayed | Провайдер сообщил задержку | Продолжать наблюдение; не отправлять повтор автоматически |
| delivered | Провайдер сообщил доставку принимающему почтовому серверу | Отметить доставку; это не подтверждение прочтения или попадания во входящие |
| bounced | Провайдер сообщил отказ доставки | Учесть причину; постоянный bounce блокирует адрес для будущих отправок |
| complained | Получена жалоба на письмо | Остановить отправку адресату и проверить сценарий |
| rejected | Письмо отклонено провайдером или отменено до передачи из-за изменения разрешений | Разобрать detail и исправить причину |
| failed | Обработка завершилась ошибкой | Разобрать detail; не приравнивать к retryable HTTP-ошибке |
| ambiguous | Письмо могло быть принято, но однозначного результата нет | Не пересылать вслепую; дождаться событий или согласовать разбор с поддержкой |
В ответе доступны id, status, from, providerMessageId, detail, createdAt и updatedAt. providerMessageId и detail могут быть null. Время передаётся в UTC. Ответ не раскрывает адрес получателя, тему и содержимое письма. detail — диагностическое значение, которое может различаться; не стройте бизнес-логику на полном перечне его строк.
Некорректный, отсутствующий, удалённый по политике хранения или принадлежащий другому проекту id возвращает одинаковый 404 email_not_found. Храните необходимый вам журнал бизнес-событий самостоятельно. Последовательность статусов не обязана включать каждый промежуточный шаг: событие доставки может прийти раньше, чем приложение увидит accepted.
Идемпотентность и безопасные повторы
Idempotency-Key обязателен для POST /emails. Он связывает повторные HTTP-запросы с одним сохранённым результатом.
| Правило | Поведение |
|---|---|
| Формат | 16–128 символов: A–Z, a–z, 0–9, точка, подчёркивание, двоеточие или дефис |
| Область действия | Конкретный API-ключ и POST /emails; другой ключ не разделяет историю повторов |
| Те же данные и тот же ключ | Возвращается сохранённый 202 с исходными id и status: queued, заголовок Idempotency-Replayed: true |
| Другие данные и тот же ключ | 409 idempotency_conflict; новое письмо не создаётся |
| Квоты | Повтор сохранённого запроса не расходует дополнительную квоту |
Выбирайте ключ по неизменяемому событию: например order-8421-completed. Сохраните окончательные данные письма до отправки и используйте их при повторе. Если при повторе заново генерировать дату, ссылку, токен или текст, запрос может конфликтовать. Для намеренно нового письма нужен новый ключ.
Если соединение оборвалось до ответа 202, повторите те же данные с тем же ключом и получите исходный id, если запись была сохранена. Ответ повторного POST содержит первоначальный результат постановки в очередь, а не актуальный статус доставки: для него используйте GET или вебхук. Не рассчитывайте на вечное хранение ключей; срок согласуйте при подключении.
Ошибки и обработка ответов
Проверяйте HTTP-статус, затем поле error. Ответ ошибки не возвращает ваши адреса и тело письма.
{
"ok": false,
"error": "quota_exceeded",
"message": "api_key hour quota exceeded",
"retryable": true
}| HTTP | error | Что делать |
|---|---|---|
| 400 | idempotency_key_required | Добавить корректный Idempotency-Key; отсутствующий и неверный формат дают одну ошибку |
| 400 / 422 | request_invalid | Проверить JSON, типы, адреса, обязательные поля и ограничения; 400 также возможен при неверном Content-Length |
| 401 | authentication_required | Проверить Bearer-ключ, срок и доступ проекта; обычно повтор без изменения доступа бесполезен |
| 403 | account_paused | Отправка остановлена или разрешения изменились; обратиться в поддержку |
| 403 | volume_anomaly_paused | Обнаружен скачок объёма; остановить автоматическую отправку и согласовать проверку |
| 404 | email_not_found | Проверить id и проект; отсутствующие и чужие письма не различаются |
| 409 | idempotency_conflict | Восстановить исходные данные операции либо выбрать новый ключ для намеренно нового письма |
| 413 | request_too_large | Уменьшить полный HTTP-body |
| 422 | sender_not_allowed | Использовать активный разрешённый адрес и правильное имя отправителя |
| 422 | recipient_suppressed | Не повторять отправку адресату; проверить постоянный bounce или жалобу |
| 429 | quota_exceeded | Замедлить поток, дождаться нового окна или согласовать лимиты |
| 503 | sending_disabled | Production-отправка выключена; дождаться подтверждённой активации |
| 503 | storage_capacity_guard | Временно приостановить новые отправки и повторять с задержкой после восстановления ёмкости |
retryable описывает возможность повторить отклонённый API-запрос после устранения временного условия. true не обещает, что немедленный повтор сработает: для sending_disabled нужна активация, для quota_exceeded — новое окно или другой согласованный лимит. Используйте ограниченное число попыток, нарастающую задержку и случайный разброс.
Поле retryable отсутствует в успешном GET и в данных вебхука. Статус failed не является командой переслать письмо. Для неизвестного ответа прокси, 5xx или сетевого исключения сохраняйте исходные данные и Idempotency-Key, чтобы повторная отправка запроса не создала новую операцию. API не публикует Retry-After и заголовки остатка квот.
Квоты, ограничения и репутация
Лимиты согласуются при подключении. Публичного обещания неограниченной отправки или универсальной квоты нет.
Часовые, дневные и месячные квоты применяются одновременно к ключу, проекту и организации. Новый принятый в очередь запрос расходует единицу во всех трёх областях; ошибка проверки и идемпотентный повтор не расходуют квоту. Окна календарные и считаются в UTC: час, день и месяц, а не скользящий период от вашей первой отправки.
| Ограничение | Текущий контракт |
|---|---|
| Получатели | Один адрес на запрос |
| Содержимое | text + html: максимум 200 000 байт UTF-8 |
| HTTP-body | Максимум 256 KiB вместе с JSON |
| Теги | До 10 пар; только допустимые ASCII-символы |
| Вложения / CC / BCC | Не поддерживаются |
| Лимиты объёма | Отдельные hour/day/month для ключа, проекта и организации |
| Публичный SLA / цены | Согласуются при проверке; не выводятся из тарифов почтовых ящиков |
Постоянные bounce и жалобы добавляют получателя в suppression внутри вашей организации. Новая попытка для такого адреса отклоняется как recipient_suppressed до расходования квоты. Не обходите suppression сменой ключа или проекта. Исправьте причину и обсудите дальнейшие действия с Mailcore.
Высокая доля постоянных отказов, жалобы или нетипичный скачок объёма могут остановить отправку организации для проверки. Наращивайте объём постепенно, отправляйте только ожидаемые уведомления и отслеживайте ошибки шаблонов. Квота разрешает поставить письмо в очередь, но не гарантирует доставку; ограничения принимающего провайдера и репутация домена действуют отдельно.
Вебхуки: события и подпись
Вебхук позволяет получать изменения состояния без регулярного опроса. Подключение адреса и подписок в пилоте выполняется с оператором.
Укажите публичный HTTPS-адрес на порту 443. URL не должен содержать логин, пароль, query string или fragment. Локальные и частные адреса, смешанные DNS-ответы и домены Mailcore недопустимы. Mailcore проверяет DNS при каждой попытке, проверяет TLS и не следует перенаправлениям. Локальный сервер разработки напрямую не подходит; используйте отдельное доступное HTTPS-окружение.
| type | data.status |
|---|---|
| email.accepted | accepted |
| email.delayed | delayed |
| email.delivered | delivered |
| email.bounced | bounced |
| email.complained | complained |
| email.rejected | rejected |
| email.failed | failed |
| email.ambiguous | ambiguous |
Подписывайтесь только на нужные типы. Событий email.queued и email.sending нет. Тело содержит id события, type, createdAt и data с emailId, status и detail. Адрес получателя и содержимое письма не передаются. Один emailId может иметь несколько событий; Webhook-Id идентифицирует конкретную доставляемую запись события.
Webhook-Id: evt_0123456789abcdef01234567
Webhook-Timestamp: 1791964800
Webhook-Signature: v1,<hex HMAC-SHA256><Webhook-Id>.<Webhook-Timestamp>.<исходные байты HTTP-body>
signature = HMAC-SHA256(UTF-8 signing secret, signed bytes)
Webhook-Signature = v1, + lowercase hexadecimal digest- Сначала получите исходные байты body, до JSON.parse или любого преобразования. Повторная сериализация JSON, изменение пробелов или Unicode нарушит подпись.
- Проверьте формат трёх заголовков и вычислите HMAC-SHA256 отдельным секретом вебхука. Это буквальные UTF-8 байты секрета, а не Bearer-ключ и не base64-декодированное значение.
- Сравните подпись в постоянном времени. Рекомендуемая локальная политика — отклонять timestamp с отклонением от ваших часов более 300 секунд в любую сторону; синхронизируйте часы сервера.
- После проверки атомарно сохраните событие и Webhook-Id в устойчивом хранилище. Повтор одного id должен быть безопасен. Ответьте 2xx после устойчивого приёма, а долгую бизнес-обработку выполните отдельно.
Обработчики вебхуков
Примеры проверяют подпись и сохраняют событие в устойчивый SQLite inbox перед подтверждением. Подключите обработчик вашей бизнес-логики перед production.
| Обработчик | Требования |
|---|---|
| Node.js | Node.js 22.13+; встроенный node:sqlite, без дополнительных пакетов |
| Python | Python 3.10+; стандартная библиотека и SQLite |
| PHP | PHP 8.1+ и расширение SQLite3; без Composer |
Локальные примеры слушают 127.0.0.1 на пути /mailcore-webhook. Задайте MAILCORE_WEBHOOK_SECRET и постоянный путь MAILCORE_WEBHOOK_INBOX. Для production нужен публичный HTTPS:443 reverse proxy и регистрация endpoint у оператора. Proxy должен сохранять исходные байты, отклонять повторяющиеся заголовки подписи и для Python/PHP буферизовать chunked-body; Python требует один корректный Content-Length. PHP SAPI может скрыть дубли заголовков, поэтому их проверка на proxy обязательна.
{
"id": "evt_0123456789abcdef01234567",
"type": "email.delivered",
"createdAt": "2026-10-14T08:00:02.000Z",
"data": {
"emailId": "eml_0123456789abcdef01234567",
"status": "delivered",
"detail": "ses:delivery"
}
}// Node.js 22.13+; only built-in modules. Local receiver for integration testing.
// Put behind public HTTPS:443 in production; register it through the operator.
// Use a persistent inbox volume. The unique ID survives restarts; an application
// worker must apply business changes transactionally/idempotently from this
// inbox. Receipt here does NOT mean a business action has been performed.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
const EVENT_STATUSES = new Set(['accepted', 'delayed', 'delivered', 'bounced', 'complained', 'rejected', 'failed', 'ambiguous']);
const MAX_BODY = 65_536;
export function verifyWebhook(rawBody, headers, secret, { now = Math.floor(Date.now() / 1000), toleranceSeconds = 300 } = {}) {
if (!Buffer.isBuffer(rawBody) || rawBody.length > MAX_BODY || typeof secret !== 'string' || !secret) {
throw new Error('Invalid webhook input');
}
const normalized = new Map();
for (const [name, value] of Object.entries(headers)) {
const key = name.toLowerCase();
if (!key.startsWith('webhook-')) continue;
if (normalized.has(key) || (Array.isArray(value) && value.length !== 1)) throw new Error('Duplicate header');
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') throw new Error('Invalid header');
normalized.set(key, text);
}
const id = normalized.get('webhook-id') ?? '';
const timestamp = normalized.get('webhook-timestamp') ?? '';
const signature = normalized.get('webhook-signature') ?? '';
if (!/^evt_[a-f0-9]{24}$/.test(id) || !/^[0-9]+$/.test(timestamp) || !/^v1,[a-f0-9]{64}$/.test(signature)) {
throw new Error('Invalid signed headers');
}
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || !Number.isFinite(now) || !Number.isFinite(toleranceSeconds) || toleranceSeconds < 0 || Math.abs(now - seconds) > toleranceSeconds) {
throw new Error('Timestamp outside tolerance');
}
// Sign the raw bytes BEFORE parsing JSON. The literal secret is UTF-8 text,
// not a base64-decoded key. Never stringify a parsed object for verification.
const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(Buffer.from(`${id}.${timestamp}.`, 'ascii')).update(rawBody).digest();
if (!timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'))) throw new Error('Invalid signature');
const event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
if (!event || typeof event !== 'object' || Array.isArray(event) || event.id !== id || typeof event.createdAt !== 'string' || Number.isNaN(Date.parse(event.createdAt))) {
throw new Error('Invalid event');
}
const data = event.data;
if (!data || typeof data !== 'object' || Array.isArray(data) || !/^eml_[a-f0-9]{24}$/.test(data.emailId ?? '') || !EVENT_STATUSES.has(data.status) || event.type !== `email.${data.status}` || !(data.detail === null || typeof data.detail === 'string')) {
throw new Error('Invalid event data');
}
return event;
}
export function startReceiver() {
const secret = process.env.MAILCORE_WEBHOOK_SECRET;
if (!secret) throw new Error('Set MAILCORE_WEBHOOK_SECRET; never log it');
const port = Number(process.env.PORT ?? 8080);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT');
const inbox = new DatabaseSync(process.env.MAILCORE_WEBHOOK_INBOX ?? 'mailcore-webhook-inbox.sqlite3');
inbox.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);');
const insert = inbox.prepare('INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (?, ?)');
const existing = inbox.prepare('SELECT raw_body FROM inbox WHERE id = ?');
const server = createServer((req, res) => {
const reply = (status, error) => {
if (res.writableEnded) return;
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(error ? { ok: false, error } : { ok: true }));
};
if (req.url !== '/mailcore-webhook') { req.resume(); reply(404, 'not_found'); return; }
if (req.method !== 'POST') { req.resume(); res.setHeader('Allow', 'POST'); reply(405, 'method_not_allowed'); return; }
if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
req.resume(); reply(415, 'content_type_required'); return;
}
const chunks = [];
let length = 0;
let rejected = false;
req.on('data', chunk => {
length += chunk.length;
if (length > MAX_BODY) { rejected = true; chunks.length = 0; reply(413, 'body_too_large'); return; }
if (!rejected) chunks.push(chunk);
});
req.on('error', () => reply(400, 'request_invalid'));
req.on('end', () => {
if (rejected) return;
const rawBody = Buffer.concat(chunks);
let event;
try { event = verifyWebhook(rawBody, req.headersDistinct, secret); }
catch { reply(401, 'webhook_invalid'); return; }
try {
// SQLite commits the unique inbox row before we acknowledge. Retain
// IDs for your retry/recovery policy and monitor unprocessed backlog.
insert.run(event.id, rawBody);
if (!Buffer.from(existing.get(event.id).raw_body).equals(rawBody)) {
reply(409, 'event_id_conflict'); return;
}
reply(200);
} catch { reply(500, 'inbox_unavailable'); }
});
});
server.requestTimeout = 10_000;
server.headersTimeout = 10_000;
server.listen(port, '127.0.0.1');
return server;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) startReceiver();
"""Python 3.10+, standard library. Local receiver; production needs HTTPS:443.
An operator configures the webhook. Persist the SQLite inbox on durable storage.
An application worker must apply business changes transactionally/idempotently
from this inbox. HTTP 200 acknowledges durable receipt, not a business action.
"""
import hashlib
import hmac
from contextlib import closing
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import math
import os
import re
import sqlite3
import time
MAX_BODY = 65_536
EVENT_STATUSES = {"accepted", "delayed", "delivered", "bounced", "complained", "rejected", "failed", "ambiguous"}
def verify_webhook(raw_body, headers, secret, *, now=None, tolerance_seconds=300):
if not isinstance(raw_body, bytes) or len(raw_body) > MAX_BODY or not isinstance(secret, str) or not secret:
raise ValueError("Invalid webhook input")
normalized = {}
for name, value in headers.items():
key = name.lower()
if not key.startswith("webhook-"):
continue
if key in normalized or (isinstance(value, (list, tuple)) and len(value) != 1):
raise ValueError("Duplicate header")
text = value[0] if isinstance(value, (list, tuple)) else value
if not isinstance(text, str):
raise ValueError("Invalid header")
normalized[key] = text
event_id = normalized.get("webhook-id", "")
timestamp = normalized.get("webhook-timestamp", "")
signature = normalized.get("webhook-signature", "")
if not re.fullmatch(r"evt_[a-f0-9]{24}", event_id) or not re.fullmatch(r"[0-9]{1,13}", timestamp) or not re.fullmatch(r"v1,[a-f0-9]{64}", signature):
raise ValueError("Invalid signed headers")
current = time.time() if now is None else now
if not math.isfinite(current) or not math.isfinite(tolerance_seconds) or tolerance_seconds < 0 or abs(current - int(timestamp)) > tolerance_seconds:
raise ValueError("Timestamp outside tolerance")
# Exact raw request bytes. The literal secret is UTF-8, not base64-decoded.
signed = (event_id + "." + timestamp + ".").encode("ascii") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature[3:]):
raise ValueError("Invalid signature")
event = json.loads(raw_body.decode("utf-8"))
if not isinstance(event, dict) or event.get("id") != event_id or not isinstance(event.get("createdAt"), str):
raise ValueError("Invalid event")
data = event.get("data")
if not isinstance(data, dict) or not isinstance(data.get("emailId"), str) or not re.fullmatch(r"eml_[a-f0-9]{24}", data["emailId"]):
raise ValueError("Invalid email ID")
status = data.get("status")
if not isinstance(status, str) or status not in EVENT_STATUSES or event.get("type") != "email." + status or "detail" not in data or not (data["detail"] is None or isinstance(data["detail"], str)):
raise ValueError("Invalid event data")
return event
def main():
secret = os.environ.get("MAILCORE_WEBHOOK_SECRET", "")
if not secret:
raise SystemExit("Set MAILCORE_WEBHOOK_SECRET; never log it")
inbox_path = os.environ.get("MAILCORE_WEBHOOK_INBOX", "mailcore-webhook-inbox.sqlite3")
with closing(sqlite3.connect(inbox_path)) as connection, connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)")
class Receiver(BaseHTTPRequestHandler):
# Quiet by default: never log bodies, tokens or webhook secrets.
def log_message(self, format, *args):
pass
def respond(self, status, error=None):
body = json.dumps({"ok": not bool(error), **({"error": error} if error else {})}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
if status == 405:
self.send_header("Allow", "POST")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
self.close_connection = True
def do_POST(self):
self.connection.settimeout(10)
if self.path != "/mailcore-webhook":
self.respond(404, "not_found")
return
if self.headers.get("Content-Type", "").split(";")[0].strip().lower() != "application/json":
self.respond(415, "content_type_required")
return
# This small demo does not parse chunked transfer coding. A reverse
# proxy must buffer/dechunk and supply one Content-Length header.
lengths = self.headers.get_all("Content-Length", [])
if self.headers.get("Transfer-Encoding") or len(lengths) != 1 or not re.fullmatch(r"[0-9]{1,10}", lengths[0]):
self.respond(400, "content_length_required")
return
size = int(lengths[0])
if size > MAX_BODY:
self.respond(413, "body_too_large")
return
try:
raw_body = self.rfile.read(size)
if len(raw_body) != size:
self.respond(400, "request_invalid")
return
signed_headers = {name: self.headers.get_all(name, []) for name in ("Webhook-Id", "Webhook-Timestamp", "Webhook-Signature")}
event = verify_webhook(raw_body, signed_headers, secret)
except (ValueError, UnicodeError, OSError, TypeError):
self.respond(401, "webhook_invalid")
return
try:
with closing(sqlite3.connect(inbox_path)) as connection, connection:
connection.execute("PRAGMA synchronous=FULL")
connection.execute("INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (?, ?)", (event["id"], raw_body))
saved = connection.execute("SELECT raw_body FROM inbox WHERE id = ?", (event["id"],)).fetchone()[0]
if saved != raw_body:
self.respond(409, "event_id_conflict")
return
# Transaction has committed. A repeat now safely acknowledges
# the persisted ID; process the inbox separately and monitor it.
self.respond(200)
except sqlite3.Error:
self.respond(500, "inbox_unavailable")
def do_GET(self):
self.respond(405, "method_not_allowed")
do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_HEAD = do_TRACE = do_GET
port = int(os.environ.get("PORT", "8080"))
HTTPServer(("127.0.0.1", port), Receiver).serve_forever()
if __name__ == "__main__":
main()
<?php
// PHP 8.1+ and the bundled SQLite3 extension; no Composer packages.
// Local demo: php -S 127.0.0.1:8080 webhook-php.php
// Production needs public HTTPS:443 plus operator registration and durable DB.
// Inbox receipt is durable. A worker must apply business actions from the inbox
// transactionally/idempotently; this handler does not perform those actions.
declare(strict_types=1);
function verifyWebhook(string $rawBody, array $headers, string $secret, ?int $now = null, int $toleranceSeconds = 300): array {
if ($secret === '' || strlen($rawBody) > 65536) throw new RuntimeException('Invalid input');
$normalized = [];
foreach ($headers as $name => $value) {
$key = strtolower($name);
if (!str_starts_with($key, 'webhook-')) continue;
if (array_key_exists($key, $normalized) || (is_array($value) && count($value) !== 1)) throw new RuntimeException('Duplicate header');
$value = is_array($value) ? array_values($value)[0] : $value;
if (!is_string($value)) throw new RuntimeException('Invalid header');
$normalized[$key] = $value;
}
$id = $normalized['webhook-id'] ?? '';
$timestamp = $normalized['webhook-timestamp'] ?? '';
$signature = $normalized['webhook-signature'] ?? '';
if (!preg_match('/^evt_[a-f0-9]{24}$/D', $id) || !preg_match('/^[0-9]{1,13}$/D', $timestamp) || !preg_match('/^v1,[a-f0-9]{64}$/D', $signature)) throw new RuntimeException('Invalid signed headers');
if ($toleranceSeconds < 0 || abs(($now ?? time()) - (int) $timestamp) > $toleranceSeconds) throw new RuntimeException('Stale timestamp');
// Exact bytes BEFORE JSON parsing. Use the literal secret as UTF-8 text.
$expected = hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, substr($signature, 3))) throw new RuntimeException('Invalid signature');
$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($event) || ($event['id'] ?? null) !== $id || !is_string($event['createdAt'] ?? null)) throw new RuntimeException('Invalid event');
$data = $event['data'] ?? null;
$statuses = ['accepted', 'delayed', 'delivered', 'bounced', 'complained', 'rejected', 'failed', 'ambiguous'];
if (!is_array($data) || !is_string($data['emailId'] ?? null) || !preg_match('/^eml_[a-f0-9]{24}$/D', $data['emailId']) || !in_array($data['status'] ?? null, $statuses, true) || ($event['type'] ?? null) !== 'email.' . $data['status'] || !array_key_exists('detail', $data) || !($data['detail'] === null || is_string($data['detail']))) throw new RuntimeException('Invalid event data');
return $event;
}
function respond(int $status, ?string $error = null): never {
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
if ($status === 405) header('Allow: POST');
echo json_encode($error === null ? ['ok' => true] : ['ok' => false, 'error' => $error], JSON_THROW_ON_ERROR);
exit;
}
// Safe to require this file from a local verification test without serving.
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
if (($_SERVER['REQUEST_URI'] ?? '') !== '/mailcore-webhook') respond(404, 'not_found');
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') respond(405, 'method_not_allowed');
if (strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0])) !== 'application/json') respond(415, 'content_type_required');
// Buffer/dechunk and reject duplicate signed headers at the reverse proxy;
// PHP SAPI can overwrite duplicate headers before the application sees them.
$rawBody = file_get_contents('php://input', false, null, 0, 65537);
if ($rawBody === false) respond(400, 'request_invalid');
if (strlen($rawBody) > 65536) respond(413, 'body_too_large');
$secret = getenv('MAILCORE_WEBHOOK_SECRET') ?: '';
if ($secret === '') respond(500, 'receiver_not_configured');
try {
$event = verifyWebhook($rawBody, [
'Webhook-Id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
'Webhook-Timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
'Webhook-Signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
], $secret);
} catch (Throwable $error) { respond(401, 'webhook_invalid'); }
try {
$inbox = new SQLite3(getenv('MAILCORE_WEBHOOK_INBOX') ?: 'mailcore-webhook-inbox.sqlite3');
$inbox->enableExceptions(true);
$inbox->busyTimeout(5000);
$inbox->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);');
$insert = $inbox->prepare('INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (:id, :body)');
$insert->bindValue(':id', $event['id'], SQLITE3_TEXT);
$insert->bindValue(':body', $rawBody, SQLITE3_BLOB);
$insert->execute(); // Autocommit completes before acknowledging.
$query = $inbox->prepare('SELECT raw_body FROM inbox WHERE id = :id');
$query->bindValue(':id', $event['id'], SQLITE3_TEXT);
if ($query->execute()->fetchArray(SQLITE3_ASSOC)['raw_body'] !== $rawBody) respond(409, 'event_id_conflict');
$inbox->close();
respond(200); // Already persisted duplicates also acknowledge safely.
} catch (Throwable $error) { respond(500, 'inbox_unavailable'); }
}
Не ограничивайтесь Set или словарём в памяти для дедупликации: состояние теряется при перезапуске и не разделяется между экземплярами. Используйте уникальный индекс Webhook-Id в базе и транзакцию, которая сохраняет событие вместе с заданием обработки. Подтверждайте уже сохранённый корректный повтор ответом 2xx.
В этих обработчиках 200 подтверждает только устойчивое сохранение в inbox, а не выполненное бизнес-действие. Отдельный worker должен читать inbox и применять изменения транзакционно и идемпотентно: например, сохранять результат операции вместе с отметкой обработки, чтобы сбой между действием и отметкой не повторил действие. SQLite-файл должен переживать перезапуск; для нескольких серверов адаптируйте хранилище и распределение задач.
Не перезаписывайте итоговое состояние более ранним событием только потому, что оно пришло позже. Обработку привязывайте к emailId, а перед важным действием при необходимости сверяйтесь с GET /emails/{id}. Никогда не отправляйте новое письмо прямо из обработчика email.ambiguous без решения о допустимости дубликата.
Проверка перед production
Прежде чем включать поток реальных уведомлений, проверьте весь путь: событие приложения → очередь → API → состояние → ваша обработка вебхука.
- Получено отдельное подтверждение активации; домен и DKIM проверены Mailcore, sender binding совпадает с запросом, объёмы согласованы.
- Ключ хранится только на сервере; предусмотрены отзыв и замена. Логи скрывают Authorization, секрет подписи, получателей, тела писем и чувствительные ссылки.
- Idempotency-Key и данные операции сохраняются до отправки. Потерянный ответ повторяется с тем же ключом; переключение на новый ключ не дублирует старые операции.
- 202, accepted, delivered, bounced, complained и ambiguous обработаны как разные состояния. Пользовательский интерфейс не сообщает «доставлено» сразу после 202.
- 429 и временные 503 ограничивают поток; повторные попытки имеют предел, задержку и случайный разброс. Изменение разрешений останавливает отправку.
- Вебхук проверяет исходные байты и timestamp, сохраняет событие до 2xx, выдерживает повторы и изменение порядка. Дедупликация работает между экземплярами и после перезапуска.
- Проверены постоянный отказ, жалоба, suppression, отзыв ключа и остановка проекта; есть контакт поддержки и ответственный за инциденты.
Тесты проводите по согласованному сценарию и с разрешёнными получателями. Внутренний fake-адаптер предназначен для локальных тестов Mailcore: он не отправляет письмо, не является клиентской песочницей и не подтверждает реальную доставку. Доступный healthz также не доказывает готовность production-отправки.
При обращении за помощью передайте emailId, время UTC, HTTP-статус, error и неперсональную часть сценария. Не присылайте секреты, полное письмо или приватную ссылку сброса пароля. При ambiguous сохраните исходную операцию и дождитесь разбора; решение о повторной отправке должно учитывать риск дубликата.
Частые вопросы
Почему после подтверждения TXT нет ключа?
TXT доказывает владение доменом. Текущая заявка заканчивается на review_pending; проверка сценария, готовность SES/DKIM и активация выполняются отдельно с оператором. На 14.10.2026 production-отправка выключена. Выдачи ключей и автоматического тестового письма в браузерной форме нет.
Нужно ли переносить корпоративную почту в Mailcore?
Email API — отдельный путь отправки приложением. Подключение API само по себе не требует смены MX и миграции входящей почты. Реальную доступность вашего домена и необходимые DNS-записи согласуйте при активации; отдельный отправляющий поддомен может быть удобнее.
Можно ли передать несколько получателей или вложение?
В версии v1 принимается ровно один адрес to. CC, BCC, вложения и пользовательские заголовки не поддерживаются. Для чека или документа можно использовать контролируемую ссылку в письме, учитывая права доступа и срок её действия.
Почему accepted не означает delivered?
accepted подтверждает приём SES. delivered появляется по событию доставки принимающему серверу. Даже delivered не доказывает попадание во входящие и прочтение. Задержка или последующая жалоба также могут изменить наблюдаемое состояние.
Что делать, если письмо осталось ambiguous?
Провайдер мог принять письмо до таймаута. Не создавайте повтор с новым ключом автоматически. Сохраните emailId, дождитесь события или обратитесь в поддержку: более позднее подтверждённое событие может разрешить неопределённость.
Есть ли API для ключей, доменов и оплаты?
В этом публичном send-контракте нет управления ключами, доменами, вебхуками или оплатой. Во время пилота эти операции сопровождает оператор. Bearer-ключ отправки не предоставляет административный доступ. Стоимость и объёмы обсуждаются при проверке заявки.
Что проверяет /healthz?
Это единственный метод без авторизации. Он возвращает 200 с {"ok":true} либо 503 с {"ok":false} и не раскрывает детали зависимостей. Успешный healthz не подтверждает работу SES, обработчика событий или разрешение отправки.
OpenAPI, Postman и примеры
Используйте машиночитаемый контракт и готовые HTTP-примеры в своём рабочем процессе.
- OpenAPI: /docs/email-api/openapi.json — методы, заголовки, схемы запросов, ответов и ошибок.
- Postman: /docs/email-api/postman.json — коллекция для отправки, чтения состояния и healthz. Настройте baseUrl и API-ключ локально; не публикуйте окружение с секретом.
- Примеры cURL, Node.js, Python и PHP можно скопировать из разделов выше или скачать вместе с документацией.
Спецификация размещена как статический файл документации. Сам production-процесс API не предоставляет /openapi.json, Swagger UI или интерактивную консоль. Импорт коллекции не активирует sending и не создаёт проект. Не запускайте запрос отправки до подтверждения доступа.
Отдельные опубликованные SDK-пакеты Mailcore здесь не требуются: интеграция использует стандартный HTTPS и JSON. При расхождении примера с поведением API сохраните HTTP-статус и error, проверьте дату документации и обратитесь в поддержку без передачи секретов.
Скачать
Импортируйте контракт в инструменты разработки. Примеры — обычный код, а не официальный SDK.
OpenAPI 3.1Postman CollectionДокументация для AI-инструментов