Документация

MAILCORE ДЛЯ РАЗРАБОТЧИКОВ

Email API — документация для разработчиков

Подключение Mailcore Email API, отправка транзакционных писем, идемпотентность, статусы доставки и проверка вебхуков.

HTTP API v1 · 14 октября 2026OpenAPIPostman

Что делает 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-ключ вашего проекта.

HTTP
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.

Отправка через cURL
Скачать
#!/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"}
  }'
202 Accepted — пример ответа
{
  "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.jsNode.js 18+; встроенный fetch, без дополнительных пакетов
PythonPython 3.10+; стандартная библиотека
PHPPHP 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
Скачать
// 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
Скачать
"""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
// 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}"
200 OK — пример состояния
{
  "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Обработчик начал передачу провайдеруЖдать результата
acceptedSES принял письмо и вернул идентификаторЖдать события доставки; это ещё не доставка
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
}
HTTPerrorЧто делать
400idempotency_key_requiredДобавить корректный Idempotency-Key; отсутствующий и неверный формат дают одну ошибку
400 / 422request_invalidПроверить JSON, типы, адреса, обязательные поля и ограничения; 400 также возможен при неверном Content-Length
401authentication_requiredПроверить Bearer-ключ, срок и доступ проекта; обычно повтор без изменения доступа бесполезен
403account_pausedОтправка остановлена или разрешения изменились; обратиться в поддержку
403volume_anomaly_pausedОбнаружен скачок объёма; остановить автоматическую отправку и согласовать проверку
404email_not_foundПроверить id и проект; отсутствующие и чужие письма не различаются
409idempotency_conflictВосстановить исходные данные операции либо выбрать новый ключ для намеренно нового письма
413request_too_largeУменьшить полный HTTP-body
422sender_not_allowedИспользовать активный разрешённый адрес и правильное имя отправителя
422recipient_suppressedНе повторять отправку адресату; проверить постоянный bounce или жалобу
429quota_exceededЗамедлить поток, дождаться нового окна или согласовать лимиты
503sending_disabledProduction-отправка выключена; дождаться подтверждённой активации
503storage_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-окружение.

typedata.status
email.acceptedaccepted
email.delayeddelayed
email.delivereddelivered
email.bouncedbounced
email.complainedcomplained
email.rejectedrejected
email.failedfailed
email.ambiguousambiguous

Подписывайтесь только на нужные типы. Событий 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.jsNode.js 22.13+; встроенный node:sqlite, без дополнительных пакетов
PythonPython 3.10+; стандартная библиотека и SQLite
PHPPHP 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: исходный body и HMAC
Скачать
// 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: проверка подписи
Скачать
"""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
// 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-инструментов