# Mailcore developer documentation > Public client integration documentation. Production Email API sending is currently disabled; activation is assisted. No customer sandbox is offered. - [RU Email API](https://mailcore.uz/docs/email-api/) - [EN Email API](https://mailcore.uz/en/docs/email-api/) - [UZ Email API](https://mailcore.uz/uz/docs/email-api/) - [OpenAPI 3.1](https://mailcore.uz/docs/email-api/openapi.json) - [Postman Collection](https://mailcore.uz/docs/email-api/postman.json) # Email API — документация для разработчиков Подключение Mailcore Email API, отправка транзакционных писем, идемпотентность, статусы доставки и проверка вебхуков. ## Что делает Email API Отправляйте ожидаемые сервисные письма из вашего приложения: подтверждения заказов, чеки, восстановление пароля, уведомления о входе и бронировании. Текущий запуск: приём заявок, отправка выключена На 14 октября 2026 года production-отправка Email API не включена. Публичный путь подключения — заявка на контролируемый пилот и настройка с оператором. Эти документы описывают реализованный контракт для интеграции после активации. Новый POST /emails при выключенной отправке возвращает 503 sending_disabled. Форма заявки не выдаёт 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-домена. Что получить перед первым запросом Подтверждение активации, базовый адрес API, точное разрешённое значение From, секрет API-ключа, часовые/дневные/месячные квоты и, если нужны события, адрес вебхука с отдельным секретом подписи. Подтверждённый TXT и статус заявки сами по себе не заменяют эти сведения. ## Авторизация и хранение ключа Все методы отправки и чтения состояния используют Bearer-ключ вашего проекта. ``` Authorization: Bearer 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. Отозванный ключ не восстанавливается. Новому ключу соответствует новая область идемпотентности: переносите незавершённые операции только после проверки сохранённых идентификаторов писем, чтобы не создать дубликаты. ## Первое письмо Выполните пример на сервере после подтверждённой активации. Подставьте свой разрешённый отправитель и проверенный адрес получателя. Подготовьте 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 ", "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}. Для регулярного получения событий настройте вебхуки; не делайте частый опрос без необходимости. Примеры не активируют сервис Домены .example и идентификаторы в документации демонстрационные. Они не подтверждают наличие проекта, отправителя или ключа. Пока production-отправка выключена, пример не поставит новое письмо в очередь. ## Справочник 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 . Для from имя, если оно указано, должно точно совпадать с привязкой отправителя. Если отправить только разрешённый адрес без имени, Mailcore использует имя из привязки. Произвольная замена имени или отправляющего адреса возвращает sender_not_allowed. Передавайте содержательное непустое тело письма. Если используете HTML, добавьте text для клиентов, которые не отображают HTML. Не включайте пользовательский ввод без экранирования. Полный HTTP-body ограничен 256 KiB, включая JSON-обёртку и chunked-передачу; это отдельное ограничение от размера text + html. В текущем контракте нет вложений и групповой отправки Поля cc, bcc, attachments, пользовательские headers и параметры управления SES не принимаются. Для нескольких получателей создавайте отдельные бизнес-операции и запросы с отдельными ключами идемпотентности; заранее согласуйте подходящий объём. ## Примеры 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 ', 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 "), "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() ``` ``` getenv('MAILCORE_FROM') ?: 'Example Shop ', '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 ", "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 или вебхук. Не рассчитывайте на вечное хранение ключей; срок согласуйте при подключении. ambiguous — другой вид неопределённости Идемпотентность HTTP-запроса не гарантирует однозначный ответ SES. Таймаут передачи, серверная ошибка SES или сбой после начала передачи могут дать ambiguous. Mailcore не повторяет такую передачу вслепую, поскольку это может создать два письма. Повтор POST с прежним ключом не отправит его заново; новый ключ может создать дубликат. ## Ошибки и обработка ответов Проверяйте 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, ``` ``` ..<исходные байты 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 после устойчивого приёма, а долгую бизнес-обработку выполните отдельно. Повторы и порядок событий При сетевой ошибке или ответе вне 2xx Mailcore повторяет доставку с нарастающей задержкой и ограничением попыток. По умолчанию: до 8 попыток и таймаут 10 секунд на попытку; это конфигурация, а не SLA. При повторе id и тело сохраняются, timestamp и подпись обновляются. Порядок получения не гарантируется. Ответ 410 немедленно отключает endpoint; исчерпанные доставки доступны оператору как dead. ## Обработчики вебхуков Примеры проверяют подпись и сохраняют событие в устойчивый 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() ``` ``` 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, проверьте дату документации и обратитесь в поддержку без передачи секретов. # Email API — developer documentation Connect Mailcore Email API, send transactional email, handle idempotency and delivery states, and verify webhook signatures. ## What Email API does Send expected service messages from your application: order confirmations, receipts, password resets, login alerts and booking notifications. Current release: applications open, sending disabled As of 14 October 2026, production sending through Email API is not enabled. The public setup path is an application for a controlled pilot followed by operator-assisted activation. These documents describe the implemented integration contract for use after activation. A new POST /emails returns 503 sending_disabled while sending is off. The application form does not issue an API key, send a test email or provide a sandbox. Mailcore commits a message to a durable queue, hands it to Amazon SES, and updates its state from provider events. A 202 response confirms that the job was saved, not that the recipient received it. Store the email ID and follow the outcome using a GET request or signed webhooks. Product | Purpose | Setup Corporate Email | Employee mailboxes, inbound mail, webmail and person-to-person correspondence | Mailboxes, payment and mail-domain configuration Email API | Service messages generated by your application | Project, sending domain, authorized sender and an API key after review Email API is for transactional messages with a clear relationship between the recipient and your service. Cold outreach, purchased lists, bulk marketing and unrestricted mail relay are outside this contract. Do not promise inbox placement or a particular delivery time to your users: a delivery state does not establish that a message was read. ``` https://mailcore.uz/api/email/v1 POST /emails GET /emails/{id} GET /healthz ``` ## Access and domain setup Prove domain ownership and agree on your use case first. An API key and sending permission require a separate activation step. - Open /onboarding/api/, sign in, and create or select an organization. Provide the project, service-message types, expected monthly volume, sending domain and sender local part. - Publish the exact ownership TXT value shown in your application. Mailcore checks the authoritative DNS servers. This value proves domain control, not readiness to send. - After ownership verification, accept the transactional-message policy and submit the application. review_pending means the application is awaiting review; it is not a grant of access. - During assisted setup, an operator agrees on limits, checks domain and sending infrastructure, binds the sender and transfers the one-time key secret through an agreed secure channel. Which DNS records to publish Use the instructions issued for your specific domain during assisted setup. The Mailcore ownership TXT and SES/DKIM verification records serve different purposes. The current application form does not return ready-to-use SES, DKIM, SPF, DMARC or custom MAIL FROM records. Values copied from another domain's example cannot establish your domain's readiness. Enabling the API does not require moving inbound mail or changing the MX records of existing mailboxes. You can discuss a dedicated sending subdomain, such as notify.example.uz, to separate service-mail reputation. Make DNS changes, including any MAIL FROM configuration, only from instructions for your setup. Do not create mailboxes to work around missing API-domain setup. What you need before the first request Activation confirmation, the API base URL, the exact authorized From value, an API-key secret, hourly/daily/monthly quotas, and, if events are needed, a webhook endpoint with its separate signing secret. A verified TXT record and an application status do not replace these details. ## Authentication and key storage Sending and status endpoints use the Bearer API key for your project. ``` Authorization: Bearer Content-Type: application/json Idempotency-Key: order-8421-completed ``` MAILCORE_API_KEY in these examples is a server-side environment variable, not a published working credential. The secret is shown once when created. Store it in a secret manager or protected server configuration. Never put it in a URL, repository, frontend, mobile app, localStorage, analytics or request logs. A key belongs to a project and works within that project's boundary. It is not an administrator account and does not manage applications or billing. A website session cookie, sign-in code and mailbox password cannot authenticate /api/email/v1/*. Agree on separate projects and keys for separate applications or environments. A missing, invalid, expired or revoked key returns 401 authentication_required. A paused project or organization also normally fails authentication with 401; permission changes during a request can produce 403 account_paused. Repeating the request without restoring access will not resolve the problem. If a secret is exposed Stop using it and ask Mailcore to revoke and replace it. A revoked key cannot be reactivated. A replacement key has a new idempotency scope: reconcile unfinished operations against stored email IDs before migrating them, so that a key change does not create duplicate messages. ## Send your first email Run this example on your server after activation is confirmed. Replace the sender with your authorized binding and use an approved test recipient. Set MAILCORE_API_KEY in the process environment. Create a stable Idempotency-Key of at least 16 characters for one business event, store it with the operation's data, and set it as MAILCORE_IDEMPOTENCY_KEY. This example uses a single to string and plain-text content; you may also supply html when needed. ``` #!/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 ", "to": "customer@recipient.example", "subject": "Order #8421 completed", "text": "Your order has been completed.", "tags": {"order_id": "8421"} }' ``` ``` { "id": "eml_0123456789abcdef01234567", "status": "queued" } ``` - Save id alongside the order or other operation. Use it for support, status retrieval and webhook correlation. - If the response is lost or the request times out, repeat the original request with the same idempotency key. Do not generate a new key for the same attempt. - Read the state with GET /emails/{id}. Use webhooks for ongoing event handling; avoid unnecessary frequent polling. Examples do not activate access The .example domains and documentation IDs are illustrative. They do not establish that a project, sender or API key exists. While production sending is disabled, the example will not queue a new message. ## POST /emails reference Create one email for one recipient. The request body is JSON; unknown fields are rejected. Field | Type and requirement | Rules from | String, required | 3–320 characters. An active sender address in your project on a verified domain. to | String, required | 3–320 characters; exactly one valid address. Arrays and address lists are unsupported. subject | String, required | 1–998 characters; no CR, LF, NUL or other control characters except tabs. text | String or null, optional | Plain-text content. At least text or html must be present; their combined size cannot exceed 200,000 UTF-8 bytes. html | String or null, optional | HTML content. Shares the 200,000-byte limit with text; account for multibyte characters. replyTo | String or null, optional | One valid address, 3–320 characters. Controls replies, not authorization of the sender. tags | Object with string values, optional | Up to 10 pairs. Names: 1–64 characters; values: 1–256. Only A–Z, a–z, 0–9, _ and - are allowed. Names beginning with mailcore_ in any case are reserved. Addresses can include a display name of up to 128 characters, such as Example Shop . For from, any supplied display name must exactly match the sender binding. If you supply only the authorized address, Mailcore uses the name configured in the binding. An unauthorized address or replacement display name returns sender_not_allowed. Provide meaningful, nonempty content. Add text when using HTML for clients that do not render HTML. Escape user input before inserting it into a template. The complete HTTP body is capped at 256 KiB, including the JSON envelope and chunked requests; this is separate from the combined text + html limit. No attachments or group sending in this contract The API does not accept cc, bcc, attachments, custom headers or SES control parameters. For multiple recipients, create separate business operations and requests with separate idempotency keys, and agree on the expected volume in advance. ## Node.js, Python and PHP examples These are HTTP integration examples, not separate Mailcore SDKs. They all use the same REST contract. Replace the sample addresses and order event with your data. Implement idempotency-key generation and email-ID storage in your application's business logic. Check the HTTP status before treating an operation as successful; a network exception does not prove that the request was never committed. Example | Requirements Node.js | Node.js 18+; built-in fetch, no additional packages Python | Python 3.10+; standard library PHP | PHP 8.1+ with HTTPS stream support; no Composer The scripts use MAILCORE_API_KEY and the saved MAILCORE_IDEMPOTENCY_KEY. Set MAILCORE_FROM and MAILCORE_TO for your addresses in Node.js, Python and PHP; edit the sample JSON before running cURL. MAILCORE_FROM must match your sender binding, not your website account address. ``` // 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 ', 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 "), "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() ``` ``` getenv('MAILCORE_FROM') ?: 'Example Shop ', '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); } ``` In production, use bounded timeouts, your application's queue and controlled retries. Do not send directly from a browser or record secrets and full payloads in error logs. A retry with a new Idempotency-Key is a new email even when its content matches. ## Read an email and its status GET /emails/{id} returns the current state of an email in the project associated with your API key. ``` #!/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 ", "providerMessageId": "example-provider-message-id", "detail": "ses:delivery", "createdAt": "2026-10-14T08:00:00.000Z", "updatedAt": "2026-10-14T08:00:02.000Z" } ``` Status | Meaning | Application action queued | The request is committed and waiting for processing | Save id and wait; do not create a copy sending | The worker has started handing it to the provider | Wait for the outcome accepted | SES accepted the email and returned a provider ID | Wait for delivery evidence; this is not delivery delayed | The provider reported a delivery delay | Continue observing; do not automatically send another copy delivered | The provider reported delivery to the receiving mail server | Record delivery; this does not prove inbox placement or a read bounced | The provider reported a delivery failure | Review the reason; a permanent bounce suppresses future sends to the address complained | A complaint was received | Stop sending to the recipient and review the use case rejected | Rejected by the provider or canceled before transmission after an authorization change | Inspect detail and address the cause failed | Processing ended in an error | Inspect detail; this is not the same as a retryable HTTP error ambiguous | The email may have been accepted, but the outcome is uncertain | Do not resend blindly; wait for events or investigate with support The response includes id, status, from, providerMessageId, detail, createdAt and updatedAt. providerMessageId and detail can be null. Timestamps use UTC. The response does not expose the recipient, subject or content. detail is diagnostic and may vary; do not rely on an exhaustive list of its strings for business logic. An invalid ID, a missing email, a record removed under retention policy and an email in another project all return the same 404 email_not_found. Maintain your own required business-event history. You may not observe every intermediate state: a delivery event can arrive before your application sees accepted. ## Idempotency and safe retries Idempotency-Key is required for POST /emails. It links repeated HTTP requests to one committed result. Rule | Behavior Format | 16–128 characters: A–Z, a–z, 0–9, period, underscore, colon or hyphen Scope | One API key and POST /emails; another key does not share the replay history Same data and same key | Returns the saved 202 with the original id and status: queued, plus Idempotency-Replayed: true Different data and same key | 409 idempotency_conflict; no new email is created Quotas | Replaying a committed request does not consume additional quota Choose a key from an immutable event, such as order-8421-completed. Save the final message data before sending, and reuse it on retries. Generating a new date, link, token or text during a retry can create a conflict. Use a new key for an intentionally new email. If the connection is lost before the 202 response arrives, repeat the same data with the same key to recover the original id if the request committed. A replayed POST returns the initial queueing result, not the current delivery state; use GET or a webhook for the latter. Do not assume idempotency records are kept forever; agree on retention during setup. ambiguous is a different uncertainty HTTP idempotency cannot guarantee a definite SES outcome. A transmission timeout, an SES server error or a crash after transmission starts can produce ambiguous. Mailcore does not blindly retry that transmission because it could send two messages. Repeating POST with the original key does not send it again; a new key may create a duplicate. ## Errors and response handling Check the HTTP status first, then the error field. Error responses do not echo your addresses or message content. ``` { "ok": false, "error": "quota_exceeded", "message": "api_key hour quota exceeded", "retryable": true } ``` HTTP | error | Action 400 | idempotency_key_required | Supply a valid Idempotency-Key; missing and invalid values use the same error 400 / 422 | request_invalid | Check JSON, types, addresses, required fields and limits; invalid Content-Length can also produce 400 401 | authentication_required | Check the Bearer key, expiration and project access; retrying without an access change usually does not help 403 | account_paused | Sending is paused or permissions changed; contact support 403 | volume_anomaly_paused | A volume surge triggered a pause; stop automatic sending and request review 404 | email_not_found | Check the ID and project; missing and other-project emails are indistinguishable 409 | idempotency_conflict | Restore the original operation data, or use a new key for an intentionally new email 413 | request_too_large | Reduce the complete HTTP body 422 | sender_not_allowed | Use an active authorized address with the correct sender display name 422 | recipient_suppressed | Do not retry to this recipient; investigate a permanent bounce or complaint 429 | quota_exceeded | Slow the flow, wait for a new window or agree on different limits 503 | sending_disabled | Production sending is off; wait for confirmed activation 503 | storage_capacity_guard | Pause new submissions temporarily and retry with delay after capacity is restored retryable indicates that a rejected API request may be repeated after a temporary condition is resolved. true does not promise that an immediate retry will work: sending_disabled needs activation; quota_exceeded needs a new window or an approved limit change. Use a retry limit, increasing delay and jitter. retryable is not included in successful GET responses or webhook data. The failed status is not an instruction to resend. For an unknown proxy response, a 5xx or a network exception, preserve the original data and Idempotency-Key so repeating the request does not create a new operation. The API does not publish Retry-After or remaining-quota headers. ## Quotas, limits and reputation Limits are agreed during setup. There is no public promise of unlimited sending or a universal quota. Hourly, daily and monthly quotas apply simultaneously to the key, project and organization. A new request committed to the queue consumes one unit in all three scopes; validation failures and idempotent replays do not consume quota. The windows are calendar periods in UTC: hour, day and month, not rolling periods from your first send. Limit | Current contract Recipients | One address per request Content | text + html: at most 200,000 UTF-8 bytes HTTP body | At most 256 KiB including JSON Tags | Up to 10 pairs; allowed ASCII characters only Attachments / CC / BCC | Unsupported Volume quotas | Separate hour/day/month limits for the key, project and organization Public SLA / pricing | Agreed during review; not derived from mailbox pricing Permanent bounces and complaints add the recipient to your organization's suppression list. A new request to that address returns recipient_suppressed before quota is consumed. Do not bypass suppression by changing a key or project. Address the cause and discuss the next step with Mailcore. A high permanent-bounce rate, complaints or an unusual volume surge can pause the organization for review. Increase volume gradually, send expected notifications and monitor template failures. Quota allows a message to enter the queue; it does not guarantee delivery. Receiving-provider limits and domain reputation apply separately. ## Webhooks: events and signatures Receive state changes without routine polling. During the pilot, an operator configures the endpoint and event subscriptions with you. Use a public HTTPS endpoint on port 443. Its URL must not contain credentials, a query string or a fragment. Local or private addresses, mixed DNS answers and Mailcore domains are rejected. Mailcore validates DNS for each attempt, verifies TLS and does not follow redirects. A local development server is not directly reachable; use a separate accessible HTTPS environment. 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 Subscribe to the types you need. There are no email.queued or email.sending events. The payload contains the event id, type, createdAt and data with emailId, status and detail. It does not include the recipient or message content. One emailId can have several events; Webhook-Id identifies one particular event delivery record. ``` Webhook-Id: evt_0123456789abcdef01234567 Webhook-Timestamp: 1791964800 Webhook-Signature: v1, ``` ``` .. signature = HMAC-SHA256(UTF-8 signing secret, signed bytes) Webhook-Signature = v1, + lowercase hexadecimal digest ``` - Read the raw body bytes before JSON.parse or any transformation. Reserializing JSON, changing whitespace or altering Unicode will break the signature. - Validate the three headers and compute HMAC-SHA256 with the separate webhook secret. Use the literal UTF-8 bytes of that secret, not the Bearer key and not a base64-decoded value. - Compare the signature in constant time. A recommended local policy rejects timestamps more than 300 seconds from your clock in either direction; keep your server clock synchronized. - After verification, atomically persist the event and Webhook-Id in durable storage. Repeating one ID must be safe. Return 2xx after durable acceptance, and process slow business work separately. Retries and event ordering Mailcore retries network failures and non-2xx responses with increasing delay and a bounded number of attempts. Defaults are up to 8 attempts and a 10-second timeout per attempt; these are configuration values, not an SLA. A retry retains the event ID and body but refreshes the timestamp and signature. Arrival order is not guaranteed. A 410 response immediately disables the endpoint; exhausted deliveries remain dead for operator inspection. ## Webhook handlers These examples verify signatures and persist events in a durable SQLite inbox before acknowledging receipt. Connect your business-processing worker before production. Receiver | Requirements Node.js | Node.js 22.13+; built-in node:sqlite, no additional packages Python | Python 3.10+; standard library and SQLite PHP | PHP 8.1+ and the SQLite3 extension; no Composer The local receivers bind to 127.0.0.1 at /mailcore-webhook. Set MAILCORE_WEBHOOK_SECRET and a persistent MAILCORE_WEBHOOK_INBOX path. Production requires a public HTTPS:443 reverse proxy and operator registration of the endpoint. Preserve raw bytes and reject duplicate signed headers at the proxy. Buffer/dechunk the body for Python/PHP; Python needs one valid Content-Length. PHP SAPI can hide duplicate headers, so rejecting them at the proxy is required. ``` { "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() ``` ``` 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'); } } ``` An in-memory Set or dictionary is not enough for deduplication: it is lost on restart and is not shared between instances. Use a unique database constraint on Webhook-Id and a transaction that persists the event together with its processing job. Return 2xx for a valid duplicate that has already been saved. In these receivers, 200 acknowledges durable inbox receipt, not a completed business action. A separate worker must read the inbox and apply effects transactionally and idempotently: for example, commit the operation result with its processing marker so a crash between action and marker cannot repeat the action. Keep the SQLite file across restarts; adapt storage and job coordination for multiple servers. Do not overwrite a final state with an earlier event just because it arrived later. Correlate processing by emailId and, when needed, check GET /emails/{id} before an important business action. Never automatically create a new email from an email.ambiguous handler without deciding whether a duplicate is acceptable. ## Before production traffic Check the whole path before enabling real notifications: application event → queue → API → state → your webhook processing. - Activation is separately confirmed; Mailcore has verified the domain and DKIM, the sender binding matches the request, and volume limits are agreed. - The API key is stored only on the server, with a revocation and replacement process. Logs redact Authorization, signing secrets, recipients, message bodies and sensitive links. - The Idempotency-Key and operation data are saved before sending. A lost response is retried with the same key; moving to a replacement key does not duplicate old operations. - 202, accepted, delivered, bounced, complained and ambiguous are handled as different states. The UI does not say delivered immediately after 202. - 429 and temporary 503 responses slow the flow; retries have a limit, delay and jitter. Authorization changes stop sending. - The webhook verifies raw bytes and timestamps, persists before 2xx and handles duplicates and reordered events. Deduplication works across instances and restarts. - Permanent bounces, complaints, suppression, key revocation and project pauses have been exercised; an incident owner and support contact are available. Test with an agreed scenario and approved recipients. Mailcore's internal fake adapter is for local tests: it sends nothing, is not a customer sandbox and cannot establish real delivery. A reachable healthz endpoint also does not prove that production sending is ready. For support, provide emailId, UTC time, HTTP status, error and a nonpersonal description of the use case. Do not share secrets, full message content or a private password-reset link. For ambiguous, preserve the original operation and wait for investigation; any resend decision must account for the duplicate risk. ## Frequently asked questions Why is there no key after TXT verification? TXT proves domain ownership. The current application ends at review_pending; use-case review, SES/DKIM readiness and activation are separate operator-assisted steps. Production sending is disabled as of 14 October 2026. The browser form does not issue credentials or automatically send a test email. Do I need to move corporate mail to Mailcore? Email API is a separate sending path for applications. Enabling it does not itself require changing MX or migrating inbound mail. Agree on your domain's actual readiness and DNS instructions during activation; a separate sending subdomain may be convenient. Can I send to several recipients or attach a file? Version v1 accepts exactly one to address. CC, BCC, attachments and custom headers are unsupported. A receipt or document can be available through an access-controlled link in the message, with appropriate permissions and expiration. Why is accepted different from delivered? accepted records SES acceptance. delivered comes from the event confirming delivery to the receiving server. Even delivered does not prove inbox placement or a read. Delays and later complaints can also change the observed state. What if an email remains ambiguous? The provider may have accepted it before a timeout. Do not automatically create a resend with a new key. Save emailId, wait for an event or contact support: a later verified provider event can resolve the uncertainty. Is there an API for keys, domains and billing? This public send contract does not manage keys, domains, webhooks or payments. An operator assists with these during the pilot. The sending Bearer key does not grant administrative access. Pricing and volume are discussed during application review. What does /healthz check? This is the only unauthenticated endpoint. It returns 200 with {"ok":true} or 503 with {"ok":false}, without dependency details. Successful healthz does not establish SES availability, event-consumer health or permission to send. ## OpenAPI, Postman and examples Use the machine-readable contract and HTTP examples in your development workflow. - OpenAPI: /docs/email-api/openapi.json — endpoints, headers, request schemas, responses and errors. - Postman: /docs/email-api/postman.json — a collection for sending, state retrieval and healthz. Configure baseUrl and the API key locally; do not publish an environment containing the secret. - Copy the cURL, Node.js, Python and PHP examples above or download them with the documentation. The specification is a static documentation file. The production API process does not expose /openapi.json, Swagger UI or an interactive console. Importing the collection does not activate sending or create a project. Wait for access confirmation before running the send request. Separate published Mailcore SDK packages are not required: the integration uses standard HTTPS and JSON. If an example differs from the API's behavior, save the HTTP status and error, check the documentation date, and contact support without sharing secrets. # Email API — dasturchilar uchun hujjatlar Mailcore Email API’ni ulash, tranzaksion xatlar yuborish, idempotentlik va yetkazilish holatlarini boshqarish hamda webhook imzosini tekshirish. ## Email API nima qiladi Ilovangizdan kutilgan xizmat xatlarini yuboring: buyurtma tasdig‘i, chek, parolni tiklash, tizimga kirish va band qilish haqidagi bildirishnomalar. Hozirgi bosqich: arizalar, yuborish hali o‘chirilgan 2026-yil 14-oktabr holatiga ko‘ra Email API orqali production muhitida yuborish yoqilmagan. Ulanish nazorat qilinadigan pilot uchun ariza va operator yordamidagi faollashtirish orqali amalga oshiriladi. Bu hujjatlar faollashtirishdan keyingi integratsiya uchun amalga oshirilgan API shartlarini tavsiflaydi. Yuborish o‘chirilgan paytda yangi POST /emails so‘rovi 503 sending_disabled qaytaradi. Ariza shakli API kalitini bermaydi, test xatini yubormaydi va sandbox taqdim etmaydi. Mailcore xatni doimiy navbatga saqlaydi, Amazon SES’ga uzatadi va provayder hodisalari asosida holatini yangilaydi. 202 javobi vazifa saqlanganini tasdiqlaydi, xat oluvchiga yetkazilganini emas. Xat id’sini saqlang va natijani GET so‘rovi yoki imzolangan webhook orqali kuzating. Mahsulot | Vazifasi | Ulanish Korporativ pochta | Xodimlar pochta qutilari, kiruvchi pochta, veb-pochta va odamlar o‘rtasidagi yozishmalar | Pochta qutilari, to‘lov va pochta domenini sozlash Email API | Ilova yaratadigan xizmat xatlari | Loyiha, yuboruvchi domen, ruxsat etilgan jo‘natuvchi va tekshiruvdan keyingi API kaliti Email API oluvchi bilan xizmatingiz o‘rtasida aniq aloqa bo‘lgan tranzaksion xatlar uchun mo‘ljallangan. Sovuq reklama, sotib olingan manzil bazalari, ommaviy marketing va cheklanmagan pochta relay’i bu API doirasiga kirmaydi. Foydalanuvchiga xat albatta kiruvchi qutiga tushishini yoki aniq vaqtda yetishini va’da qilmang: yetkazilish holati xat o‘qilganini isbotlamaydi. ``` https://mailcore.uz/api/email/v1 POST /emails GET /emails/{id} GET /healthz ``` ## Kirish huquqi va domenni ulash Avval domen egaligini tasdiqlang va foydalanish maqsadini kelishib oling. Kalit olish va xat yuborish huquqi alohida faollashtirishni talab qiladi. - /onboarding/api/ sahifasini oching, akkauntga kiring va tashkilot yarating yoki mavjudini tanlang. Loyiha, xizmat xatlari turlari, kutiladigan oylik hajm, yuboruvchi domen va jo‘natuvchi manzilining lokal qismini kiriting. - Arizangizda ko‘rsatilgan aynan o‘sha egalik TXT qiymatini DNS’ga joylang. Mailcore uni avtoritativ DNS serverlarida tekshiradi. Bu qiymat domenni boshqarishingizni tasdiqlaydi, yuborishga tayyorligini emas. - Egalik tasdiqlangach, tranzaksion xatlar qoidalarini qabul qiling va arizani yuboring. review_pending ariza ko‘rib chiqilishini kutayotganini bildiradi; bu yuborish huquqi berilganini anglatmaydi. - Operator yordamidagi sozlashda limitlar kelishiladi, domen va yuborish infratuzilmasi tekshiriladi, jo‘natuvchi bog‘lanadi va bir marta ko‘rsatiladigan kalit siri kelishilgan xavfsiz kanal orqali beriladi. Qaysi DNS yozuvlari kerak Operator yordamidagi sozlashda aynan sizning domeningiz uchun berilgan DNS ko‘rsatmalaridan foydalaning. Mailcore egalik TXT yozuvi va SES/DKIM tasdiqlash yozuvlari turli vazifalarni bajaradi. Hozirgi ariza shakli tayyor SES, DKIM, SPF, DMARC yoki custom MAIL FROM yozuvlarini qaytarmaydi. Boshqa domen misolidan ko‘chirilgan qiymatlar sizning domeningizni tasdiqlamaydi. API’ni ulash uchun kiruvchi pochtani ko‘chirish yoki mavjud pochta qutilarining MX yozuvlarini o‘zgartirish shart emas. Xizmat xatlari obro‘sini ajratish uchun notify.example.uz kabi alohida yuboruvchi subdomenni muhokama qilishingiz mumkin. DNS, jumladan ehtimoliy MAIL FROM sozlamalarini faqat ulanishingiz uchun berilgan ko‘rsatmalar asosida o‘zgartiring. API domeni sozlanmaganini chetlab o‘tish uchun pochta qutilari yaratmang. Birinchi so‘rovdan oldin nimalar kerak Faollashtirish tasdig‘i, API asosiy manzili, ruxsat etilgan aniq From qiymati, API kaliti siri, soatlik/kunlik/oylik kvotalar va hodisalar kerak bo‘lsa, alohida imzo siri bilan webhook manzili. TXT tasdig‘i va ariza holati bu ma’lumotlarning o‘rnini bosmaydi. ## Autentifikatsiya va kalitni saqlash Yuborish va holatni o‘qish metodlari loyihangizning Bearer API kalitidan foydalanadi. ``` Authorization: Bearer Content-Type: application/json Idempotency-Key: order-8421-completed ``` Misollardagi MAILCORE_API_KEY — serveringizdagi muhit o‘zgaruvchisi; u e’lon qilingan ishlaydigan kalit emas. Sir kalit yaratilganda bir marta ko‘rsatiladi. Uni sirlar menejerida yoki himoyalangan server konfiguratsiyasida saqlang. Kalitni URL, repozitoriy, frontend, mobil ilova, localStorage, analitika yoki so‘rov jurnallariga joylamang. Kalit loyihaga bog‘langan va faqat shu loyiha chegarasida ishlaydi. U administrator akkaunti emas, arizalar yoki hisob-kitoblarni boshqarmaydi. Sayt sessiyasining cookie’si, akkauntga kirish kodi yoki pochta qutisi paroli /api/email/v1/* uchun autentifikatsiya bermaydi. Turli ilovalar yoki muhitlar uchun alohida loyiha va kalitlarni kelishib oling. Yo‘q, noto‘g‘ri, muddati tugagan yoki bekor qilingan kalit 401 authentication_required qaytaradi. Loyiha yoki tashkilot to‘xtatilgan bo‘lsa, autentifikatsiya odatda 401 bilan tugaydi; so‘rov davomida ruxsatlar o‘zgarsa, 403 account_paused chiqishi mumkin. Kirish huquqi tiklanmasdan takroriy so‘rov muammoni hal qilmaydi. Sir oshkor bo‘lsa Kalitdan foydalanishni to‘xtating va Mailcore’dan uni bekor qilib, yangisiga almashtirishni so‘rang. Bekor qilingan kalit qayta faollashtirilmaydi. Yangi kalitning idempotentlik doirasi ham yangi: tugallanmagan amallarni ko‘chirishdan oldin saqlangan xat id’larini tekshiring, aks holda dublikat xatlar yaratilishi mumkin. ## Birinchi xatni yuborish Faollashtirish tasdiqlangach misolni serverda bajaring. Jo‘natuvchi o‘rniga o‘zingizning ruxsat etilgan manzilingizni, oluvchi o‘rniga tasdiqlangan test manzilini kiriting. Jarayon muhitida MAILCORE_API_KEY’ni sozlang. Bitta biznes hodisasi uchun kamida 16 belgidan iborat barqaror Idempotency-Key yarating, uni amal ma’lumotlari bilan saqlang va MAILCORE_IDEMPOTENCY_KEY sifatida belgilang. Misolda to bitta satr va xat oddiy matn ko‘rinishida berilgan; kerak bo‘lsa html ham qo‘shishingiz mumkin. ``` #!/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 ", "to": "customer@recipient.example", "subject": "Order #8421 completed", "text": "Your order has been completed.", "tags": {"order_id": "8421"} }' ``` ``` { "id": "eml_0123456789abcdef01234567", "status": "queued" } ``` - id’ni buyurtma yoki boshqa amal bilan birga saqlang. U yordam olish, holatni o‘qish va webhook hodisasini bog‘lash uchun kerak bo‘ladi. - Javob yo‘qolsa yoki timeout yuz bersa, dastlabki so‘rovni o‘sha idempotentlik kaliti bilan takrorlang. Bir urinish uchun yangi kalit yaratmang. - GET /emails/{id} orqali holatni tekshiring. Muntazam hodisalar uchun webhook sozlang; zaruratsiz tez-tez so‘rov yubormang. Misollar kirish huquqini faollashtirmaydi .example domenlari va hujjatlardagi id’lar namuna sifatida berilgan. Ular loyiha, jo‘natuvchi yoki kalit mavjudligini tasdiqlamaydi. Production yuborish o‘chirilgan paytda misol yangi xatni navbatga qo‘ymaydi. ## POST /emails ma’lumotnomasi Metod bitta oluvchi uchun bitta xat yaratadi. So‘rov tanasi JSON formatida; noma’lum maydonlar rad etiladi. Maydon | Turi va majburiyligi | Qoidalar from | Satr, majburiy | 3–320 belgi. Loyihangizdagi tasdiqlangan domenning faol jo‘natuvchi manzili. to | Satr, majburiy | 3–320 belgi; aynan bitta to‘g‘ri manzil. Massiv va manzillar ro‘yxati qo‘llanmaydi. subject | Satr, majburiy | 1–998 belgi; CR, LF, NUL va tabulyatsiyadan boshqa boshqaruv belgilari taqiqlangan. text | Satr yoki null, ixtiyoriy | Oddiy matn. text yoki html’dan kamida biri kerak; ikkalasi birgalikda 200 000 UTF-8 baytdan oshmasligi kerak. html | Satr yoki null, ixtiyoriy | HTML ko‘rinish. 200 000 bayt limiti text bilan umumiy; ko‘p baytli belgilarni hisobga oling. replyTo | Satr yoki null, ixtiyoriy | Bitta to‘g‘ri manzil, 3–320 belgi. Javob manzilini boshqaradi, jo‘natuvchiga ruxsat bermaydi. tags | Qiymatlari satr bo‘lgan obyekt, ixtiyoriy | Ko‘pi bilan 10 juftlik. Nom: 1–64 belgi; qiymat: 1–256. Faqat A–Z, a–z, 0–9, _ va - mumkin. Har qanday registrdagi mailcore_ bilan boshlangan nomlar rezervlangan. Manzil 128 belgigacha bo‘lgan ko‘rinadigan nomni ham o‘z ichiga olishi mumkin, masalan Example Shop . from’da nom ko‘rsatilsa, u jo‘natuvchi sozlamasidagi nomga aynan mos kelishi kerak. Faqat ruxsat etilgan manzilni nomsiz yuborsangiz, Mailcore bog‘langan nomdan foydalanadi. Ruxsatsiz manzil yoki boshqa nom sender_not_allowed qaytaradi. Mazmunli, bo‘sh bo‘lmagan xat tanasini yuboring. HTML ishlatsangiz, uni ko‘rsatmaydigan pochta mijozlari uchun text ham qo‘shing. Foydalanuvchi kiritgan matnni shablonga qo‘shishdan oldin escape qiling. To‘liq HTTP tanasi JSON o‘rami va chunked so‘rovlar bilan birga 256 KiB bilan cheklangan; bu text + html hajmidan alohida limit. Fayl ilovalari va guruhli yuborish qo‘llanmaydi cc, bcc, attachments, maxsus headers va SES boshqaruv parametrlari qabul qilinmaydi. Bir nechta oluvchi uchun alohida biznes amallari va alohida idempotentlik kalitlari bilan so‘rovlar yarating; kerakli hajmni oldindan kelishib oling. ## Node.js, Python va PHP misollari Bular HTTP integratsiya misollari, alohida Mailcore SDK’lari emas. Barchasi bir xil REST API’dan foydalanadi. Namunaviy manzillar va buyurtma hodisasini o‘z ma’lumotlaringizga almashtiring. Idempotentlik kalitini yaratish va xat id’sini saqlashni ilovangizning biznes mantiqiga kiriting. Amalni muvaffaqiyatli deb hisoblashdan oldin HTTP holatini tekshiring; tarmoq xatosi so‘rov saqlanmaganini isbotlamaydi. Misol | Talablar Node.js | Node.js 18+; o‘rnatilgan fetch, qo‘shimcha paketlarsiz Python | Python 3.10+; standart kutubxona PHP | HTTPS streams qo‘llaydigan PHP 8.1+; Composer’siz Skriptlar MAILCORE_API_KEY va saqlangan MAILCORE_IDEMPOTENCY_KEY’dan foydalanadi. Node.js, Python va PHP’da o‘z manzillaringiz uchun MAILCORE_FROM va MAILCORE_TO’ni belgilang; cURL’ni bajarishdan oldin misol JSON’ini o‘zgartiring. MAILCORE_FROM sayt akkaunti manziliga emas, jo‘natuvchi bog‘lanishiga mos kelishi kerak. ``` // 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 ', 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 "), "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() ``` ``` getenv('MAILCORE_FROM') ?: 'Example Shop ', '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 muhitida cheklangan timeout, ilovangiz navbati va nazorat qilinadigan takroriy urinishlardan foydalaning. Xatni brauzerdan bevosita yubormang, sirlar yoki to‘liq payload’ni xato jurnallariga yozmang. Yangi Idempotency-Key bilan takrorlash, mazmuni bir xil bo‘lsa ham, yangi xat yaratadi. ## Xat va uning holatini o‘qish GET /emails/{id} API kalitingizga tegishli loyihadagi xatning hozirgi holatini qaytaradi. ``` #!/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 ", "providerMessageId": "example-provider-message-id", "detail": "ses:delivery", "createdAt": "2026-10-14T08:00:00.000Z", "updatedAt": "2026-10-14T08:00:02.000Z" } ``` Holat | Ma’nosi | Ilova harakati queued | So‘rov saqlangan va qayta ishlanishni kutmoqda | id’ni saqlab, kuting; nusxa yaratmang sending | Ishchi jarayon provayderga uzatishni boshlagan | Natijani kuting accepted | SES xatni qabul qilib, provayder id’sini qaytargan | Yetkazilish hodisasini kuting; bu hali yetkazilish emas delayed | Provayder yetkazilish kechikayotganini bildirgan | Kuzatishni davom ettiring; avtomatik qayta yubormang delivered | Provayder oluvchi pochta serveriga yetkazilganini bildirgan | Yetkazilishni qayd eting; bu kiruvchi qutiga tushish yoki o‘qilganlik isboti emas bounced | Provayder yetkazilish muvaffaqiyatsizligini bildirgan | Sababni tekshiring; doimiy bounce manzilga keyingi yuborishlarni bloklaydi complained | Xat bo‘yicha shikoyat kelgan | Oluvchiga yuborishni to‘xtating va foydalanish maqsadini tekshiring rejected | Provayder rad etgan yoki ruxsat o‘zgargani uchun uzatishdan oldin bekor qilingan | detail’ni tekshirib, sababni bartaraf eting failed | Qayta ishlash xato bilan yakunlangan | detail’ni tekshiring; bu retryable HTTP xatosi bilan bir xil emas ambiguous | Xat qabul qilingan bo‘lishi mumkin, ammo natija noaniq | Ko‘r-ko‘rona qayta yubormang; hodisani kuting yoki yordam xizmatiga murojaat qiling Javobda id, status, from, providerMessageId, detail, createdAt va updatedAt bor. providerMessageId va detail null bo‘lishi mumkin. Vaqt UTC’da beriladi. Javob oluvchi manzili, mavzu yoki xat mazmunini oshkor qilmaydi. detail diagnostika uchun va o‘zgarishi mumkin; biznes mantiqini uning barcha mumkin bo‘lgan satrlarini sanashga bog‘lamang. Noto‘g‘ri id, mavjud bo‘lmagan xat, saqlash siyosati bo‘yicha o‘chirilgan yozuv va boshqa loyihadagi xat bir xil 404 email_not_found qaytaradi. Sizga kerakli biznes hodisalari tarixini o‘zingiz saqlang. Har bir oraliq holatni ko‘rishingiz shart emas: ilova accepted’ni ko‘rishidan oldin yetkazilish hodisasi kelishi mumkin. ## Idempotentlik va xavfsiz takrorlash POST /emails uchun Idempotency-Key majburiy. U takroriy HTTP so‘rovlarini bitta saqlangan natijaga bog‘laydi. Qoida | Natija Format | 16–128 belgi: A–Z, a–z, 0–9, nuqta, pastki chiziq, ikki nuqta yoki defis Doira | Bitta API kaliti va POST /emails; boshqa kalit takrorlash tarixidan foydalanmaydi Bir xil ma’lumot va bir xil kalit | Dastlabki id va status: queued bilan saqlangan 202 qaytadi; Idempotency-Replayed: true sarlavhasi qo‘shiladi Boshqa ma’lumot va bir xil kalit | 409 idempotency_conflict; yangi xat yaratilmaydi Kvotalar | Saqlangan so‘rovni takrorlash qo‘shimcha kvota sarflamaydi Kalitni o‘zgarmas hodisadan tanlang, masalan order-8421-completed. Yakuniy xat ma’lumotlarini yuborishdan oldin saqlang va takrorlashda ulardan foydalaning. Takrorlash paytida yangi sana, havola, token yoki matn yaratish konfliktga olib kelishi mumkin. Ataylab yangi xat yuborish uchun yangi kalit ishlating. 202 javobidan oldin ulanish uzilsa, o‘sha ma’lumotni o‘sha kalit bilan takrorlang: so‘rov saqlangan bo‘lsa, dastlabki id qaytadi. Takroriy POST hozirgi yetkazilish holatini emas, navbatga qo‘yishning dastlabki natijasini qaytaradi; joriy holat uchun GET yoki webhook’dan foydalaning. Idempotentlik yozuvlari abadiy saqlanishiga tayanmang; saqlash muddatini ulanishda kelishib oling. ambiguous — boshqa turdagi noaniqlik HTTP idempotentligi SES natijasi aniq bo‘lishini kafolatlamaydi. Uzatish timeout’i, SES server xatosi yoki uzatish boshlanganidan keyingi uzilish ambiguous holatini berishi mumkin. Mailcore bunday uzatishni ko‘r-ko‘rona takrorlamaydi, chunki ikkita xat yuborilishi mumkin. Dastlabki kalit bilan POST’ni takrorlash qayta yubormaydi; yangi kalit dublikat yaratishi mumkin. ## Xatolar va javoblarni qayta ishlash Avval HTTP holatini, keyin error maydonini tekshiring. Xato javobi manzillar yoki xat mazmunini qaytarmaydi. ``` { "ok": false, "error": "quota_exceeded", "message": "api_key hour quota exceeded", "retryable": true } ``` HTTP | error | Nima qilish kerak 400 | idempotency_key_required | To‘g‘ri Idempotency-Key kiriting; yo‘q va noto‘g‘ri qiymatlar uchun bir xil xato 400 / 422 | request_invalid | JSON, turlar, manzillar, majburiy maydonlar va limitlarni tekshiring; noto‘g‘ri Content-Length ham 400 berishi mumkin 401 | authentication_required | Bearer kaliti, amal qilish muddati va loyiha huquqlarini tekshiring; huquq o‘zgarmasdan takrorlash odatda yordam bermaydi 403 | account_paused | Yuborish to‘xtatilgan yoki ruxsatlar o‘zgargan; yordam xizmatiga murojaat qiling 403 | volume_anomaly_paused | Hajmning keskin oshishi to‘xtatishga sabab bo‘lgan; avtomatik yuborishni to‘xtatib, tekshiruvni kelishing 404 | email_not_found | id va loyihani tekshiring; yo‘q va boshqa loyihadagi xatlar farqlanmaydi 409 | idempotency_conflict | Dastlabki amal ma’lumotlarini tiklang yoki ataylab yangi xat uchun yangi kalit tanlang 413 | request_too_large | To‘liq HTTP tanasini kichraytiring 422 | sender_not_allowed | Faol, ruxsat etilgan manzil va to‘g‘ri jo‘natuvchi nomidan foydalaning 422 | recipient_suppressed | Bu oluvchiga qayta urinmang; doimiy bounce yoki shikoyatni tekshiring 429 | quota_exceeded | Oqimni sekinlashtiring, yangi davrni kuting yoki limitlarni kelishing 503 | sending_disabled | Production yuborish o‘chirilgan; faollashtirish tasdig‘ini kuting 503 | storage_capacity_guard | Yangi so‘rovlarni vaqtincha to‘xtating va saqlash sig‘imi tiklangach, kechikish bilan takrorlang retryable rad etilgan API so‘rovini vaqtinchalik holat bartaraf etilgach takrorlash mumkinligini bildiradi. true darhol takrorlash ishlashiga va’da bermaydi: sending_disabled uchun faollashtirish, quota_exceeded uchun yangi davr yoki kelishilgan limit o‘zgarishi kerak. Urinishlar sonini cheklang, kechikishni oshiring va tasodifiy vaqt farqini qo‘shing. Muvaffaqiyatli GET javobi va webhook ma’lumotlarida retryable yo‘q. failed holati xatni qayta yuborish buyrug‘i emas. Noma’lum proxy javobi, 5xx yoki tarmoq xatosida dastlabki ma’lumot va Idempotency-Key’ni saqlang, shunda so‘rovni takrorlash yangi amal yaratmaydi. API Retry-After yoki qolgan kvota sarlavhalarini bermaydi. ## Kvotalar, limitlar va obro‘ Limitlar ulanishda kelishiladi. Cheksiz yuborish yoki barcha uchun bir xil kvota va’dasi yo‘q. Soatlik, kunlik va oylik kvotalar kalit, loyiha va tashkilotga bir vaqtda qo‘llanadi. Navbatga saqlangan har bir yangi so‘rov uchala doirada bir birlik sarflaydi; tekshiruv xatolari va idempotent takrorlar kvota sarflamaydi. Davrlar UTC’dagi kalendar soati, kuni va oyi bilan hisoblanadi, birinchi yuborishingizdan boshlangan sirpanuvchi davr emas. Cheklov | Hozirgi API Oluvchilar | Har bir so‘rovda bitta manzil Mazmun | text + html: ko‘pi bilan 200 000 UTF-8 bayt HTTP tanasi | JSON bilan birga ko‘pi bilan 256 KiB Teglar | 10 juftlikkacha; faqat ruxsat etilgan ASCII belgilar Fayl ilovalari / CC / BCC | Qo‘llanmaydi Hajm kvotalari | Kalit, loyiha va tashkilot uchun alohida hour/day/month limitlari Ommaviy SLA / narxlar | Tekshiruvda kelishiladi; pochta qutilari narxidan olinmaydi Doimiy bounce va shikoyatlar oluvchini tashkilotingiz suppression ro‘yxatiga qo‘shadi. Bu manzilga yangi so‘rov kvota sarflanishidan oldin recipient_suppressed qaytaradi. Kalit yoki loyihani almashtirib suppression’ni chetlab o‘tmang. Sababni bartaraf eting va keyingi qadamni Mailcore bilan muhokama qiling. Doimiy rad javoblari ulushining oshishi, shikoyatlar yoki noodatiy hajm sakrashi tashkilot yuborishini tekshiruv uchun to‘xtatishi mumkin. Hajmni bosqichma-bosqich oshiring, faqat kutilgan bildirishnomalarni yuboring va shablon xatolarini kuzating. Kvota xatni navbatga qo‘yishga ruxsat beradi, lekin yetkazilishni kafolatlamaydi. Oluvchi provayderi limitlari va domen obro‘si alohida ta’sir qiladi. ## Webhook: hodisalar va imzo Holat o‘zgarishlarini muntazam GET so‘rovisiz oling. Pilotda operator endpoint va hodisalarga obunani siz bilan sozlaydi. 443 portdagi ommaviy HTTPS endpoint’dan foydalaning. URL’da login, parol, query string yoki fragment bo‘lmasligi kerak. Lokal va xususiy manzillar, aralash DNS javoblari va Mailcore domenlari rad etiladi. Mailcore har bir urinishda DNS’ni tekshiradi, TLS’ni tasdiqlaydi va redirect’ga ergashmaydi. Lokal dasturlash serveri bevosita mos kelmaydi; tashqaridan ochiq alohida HTTPS muhitdan foydalaning. 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 Faqat kerakli turlarga obuna bo‘ling. email.queued va email.sending hodisalari yo‘q. Tana hodisaning id, type, createdAt maydonlarini va emailId, status, detail bo‘lgan data obyektini o‘z ichiga oladi. Oluvchi manzili va xat mazmuni yuborilmaydi. Bir emailId uchun bir nechta hodisa bo‘lishi mumkin; Webhook-Id aniq bir hodisa yetkazish yozuvini belgilaydi. ``` Webhook-Id: evt_0123456789abcdef01234567 Webhook-Timestamp: 1791964800 Webhook-Signature: v1, ``` ``` .. signature = HMAC-SHA256(UTF-8 signing secret, signed bytes) Webhook-Signature = v1, + lowercase hexadecimal digest ``` - JSON.parse yoki boshqa o‘zgartirishdan oldin asl tana baytlarini oling. JSON’ni qayta serializatsiya qilish, bo‘shliqlar yoki Unicode’ni o‘zgartirish imzoni buzadi. - Uchta sarlavha formatini tekshiring va alohida webhook siri bilan HMAC-SHA256 hisoblang. Sirning aynan UTF-8 baytlaridan foydalaning; Bearer kaliti yoki base64’dan dekodlangan qiymatdan emas. - Imzoni doimiy vaqtda solishtiring. Tavsiya etilgan lokal qoida — vaqtingizdan ikki yo‘nalishda ham 300 soniyadan ko‘p farq qiladigan timestamp’ni rad etish; server soatini sinxronlang. - Tekshiruvdan keyin hodisa va Webhook-Id’ni doimiy saqlashga atomar tarzda yozing. Bitta id’ning takrori xavfsiz bo‘lishi kerak. Ishonchli saqlashdan keyin 2xx qaytaring, uzoq biznes amallarini alohida bajaring. Takrorlar va hodisalar tartibi Tarmoq xatosi yoki 2xx’dan boshqa javobda Mailcore cheklangan urinishlar soni bilan, oshib boruvchi kechikishda takrorlaydi. Standart sozlama: 8 urinishgacha va har urinish uchun 10 soniyalik timeout; bu SLA emas. Takrorlashda id va tana saqlanadi, timestamp va imzo yangilanadi. Kelish tartibi kafolatlanmaydi. 410 javobi endpoint’ni darhol o‘chiradi; urinishlari tugagan yozuvlar operator tekshiruvi uchun dead holatida qoladi. ## Webhook ishlov beruvchilari Misollar imzoni tekshiradi va tasdiqlashdan oldin hodisani doimiy SQLite inbox’ga saqlaydi. Production’dan oldin biznes mantiqingiz ishlov beruvchisini ulang. Qabul qiluvchi | Talablar Node.js | Node.js 22.13+; o‘rnatilgan node:sqlite, qo‘shimcha paketlarsiz Python | Python 3.10+; standart kutubxona va SQLite PHP | PHP 8.1+ va SQLite3 kengaytmasi; Composer’siz Lokal qabul qiluvchilar 127.0.0.1’da /mailcore-webhook yo‘lini tinglaydi. MAILCORE_WEBHOOK_SECRET va doimiy MAILCORE_WEBHOOK_INBOX yo‘lini belgilang. Production uchun ommaviy HTTPS:443 reverse proxy va operator orqali endpoint ro‘yxati kerak. Proxy asl baytlarni saqlashi va takroriy imzo sarlavhalarini rad etishi kerak. Python/PHP uchun chunked tanani buferlang va dechunk qiling; Python bitta to‘g‘ri Content-Length talab qiladi. PHP SAPI takroriy sarlavhalarni yashirishi mumkin, shu sababli ularni proxy’da rad etish majburiy. ``` { "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() ``` ``` 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'); } } ``` Takrorlarni aniqlash uchun faqat xotiradagi Set yoki lug‘at yetarli emas: qayta ishga tushganda yo‘qoladi va nusxalar o‘rtasida umumiy emas. Bazada Webhook-Id uchun unique cheklovi va hodisani qayta ishlash vazifasi bilan birga saqlaydigan tranzaksiyadan foydalaning. Oldin saqlangan, to‘g‘ri imzolangan takrorga 2xx qaytaring. Bu qabul qiluvchilarda 200 faqat inbox’ga ishonchli saqlanganini tasdiqlaydi, biznes amali bajarilganini emas. Alohida worker inbox’ni o‘qib, ta’sirlarni tranzaksion va idempotent tarzda qo‘llashi kerak: masalan, amal natijasi va qayta ishlash belgisini birga saqlang, shunda ular orasidagi uzilish amalni takrorlamaydi. SQLite faylini qayta ishga tushishda saqlang; bir nechta server uchun saqlash va vazifalarni taqsimlashni moslang. Avvalgi hodisa keyinroq kelgani uchun yakuniy holatni u bilan almashtirmang. Qayta ishlashni emailId bilan bog‘lang va muhim biznes amalidan oldin kerak bo‘lsa GET /emails/{id} orqali tekshiring. Dublikatga yo‘l qo‘yish mumkinligi hal qilinmasdan email.ambiguous ishlov beruvchisidan avtomatik yangi xat yubormang. ## Production’dan oldingi tekshiruv Haqiqiy bildirishnomalarni yoqishdan oldin butun yo‘lni tekshiring: ilova hodisasi → navbat → API → holat → webhook’ni qayta ishlash. - Alohida faollashtirish tasdig‘i olingan; Mailcore domen va DKIM’ni tekshirgan, jo‘natuvchi bog‘lanishi so‘rovga mos, hajm limitlari kelishilgan. - Kalit faqat serverda saqlanadi; bekor qilish va almashtirish tartibi bor. Jurnallarda Authorization, imzo siri, oluvchilar, xat tanasi va maxfiy havolalar yashiriladi. - Idempotency-Key va amal ma’lumotlari yuborishdan oldin saqlanadi. Yo‘qolgan javob o‘sha kalit bilan takrorlanadi; yangi kalitga o‘tish eski amallarni takrorlamaydi. - 202, accepted, delivered, bounced, complained va ambiguous alohida holatlar sifatida qayta ishlanadi. Interfeys 202’dan keyin darhol «yetkazildi» demaydi. - 429 va vaqtinchalik 503 oqimni sekinlashtiradi; urinishlar soni, kechikish va tasodifiy vaqt farqi cheklangan. Ruxsat o‘zgarishi yuborishni to‘xtatadi. - Webhook asl baytlar va timestamp’ni tekshiradi, 2xx’dan oldin saqlaydi, takrorlar va tartib almashishiga chidamli. Takrorlarni aniqlash bir nechta nusxa va qayta ishga tushishda ishlaydi. - Doimiy bounce, shikoyat, suppression, kalitni bekor qilish va loyihani to‘xtatish sinovdan o‘tgan; hodisalarga mas’ul shaxs va yordam kontakti bor. Testlarni kelishilgan ssenariy va ruxsat etilgan oluvchilar bilan bajaring. Mailcore’ning ichki fake adapteri lokal testlar uchun: u xat yubormaydi, mijoz sandbox’i emas va haqiqiy yetkazilishni tasdiqlamaydi. healthz ochiq bo‘lishi ham production yuborish tayyorligini isbotlamaydi. Yordam uchun emailId, UTC vaqti, HTTP holati, error va shaxsiy ma’lumot bo‘lmagan ssenariy tavsifini yuboring. Sirlar, to‘liq xat yoki maxfiy parol tiklash havolasini bermang. ambiguous’da dastlabki amalni saqlang va tekshiruvni kuting; qayta yuborish qarori dublikat xavfini hisobga olishi kerak. ## Ko‘p beriladigan savollar Nega TXT tasdig‘idan keyin kalit yo‘q? TXT domen egaligini tasdiqlaydi. Hozirgi ariza review_pending’da tugaydi; foydalanish maqsadini tekshirish, SES/DKIM tayyorligi va faollashtirish operator bilan alohida bajariladi. 14.10.2026 holatiga production yuborish o‘chirilgan. Brauzer shakli kalit bermaydi va avtomatik test xati yubormaydi. Korporativ pochtani Mailcore’ga ko‘chirish kerakmi? Email API — ilovalar uchun alohida yuborish yo‘li. Uni ulashning o‘zi MX’ni o‘zgartirish yoki kiruvchi pochtani ko‘chirishni talab qilmaydi. Domenning haqiqiy tayyorligi va DNS ko‘rsatmalarini faollashtirishda kelishib oling; alohida yuboruvchi subdomen qulay bo‘lishi mumkin. Bir nechta oluvchi yoki fayl ilovasini yuborish mumkinmi? v1 versiyasi aynan bitta to manzilini qabul qiladi. CC, BCC, fayl ilovalari va maxsus sarlavhalar qo‘llanmaydi. Chek yoki hujjatni xatdagi boshqariladigan havola orqali berish mumkin; kirish huquqi va amal qilish muddatini hisobga oling. Nega accepted delivered’dan farq qiladi? accepted SES qabul qilganini qayd etadi. delivered oluvchi serveriga yetkazilish hodisasidan olinadi. Hatto delivered ham kiruvchi qutiga tushish va o‘qilganlikni isbotlamaydi. Kechikish yoki keyingi shikoyat kuzatilayotgan holatni o‘zgartirishi mumkin. Xat ambiguous’da qolsa nima qilish kerak? Provayder timeout’dan oldin xatni qabul qilgan bo‘lishi mumkin. Yangi kalit bilan avtomatik qayta yubormang. emailId’ni saqlang, hodisani kuting yoki yordamga murojaat qiling: keyinroq kelgan tasdiqlangan hodisa noaniqlikni bartaraf etishi mumkin. Kalitlar, domenlar va to‘lov uchun API bormi? Bu ommaviy yuborish API’si kalitlar, domenlar, webhook’lar yoki to‘lovni boshqarmaydi. Pilot davomida operator yordam beradi. Yuborish Bearer kaliti administrator huquqini bermaydi. Narx va hajm arizani ko‘rib chiqishda muhokama qilinadi. /healthz nimani tekshiradi? Bu autentifikatsiyasiz yagona metod. U bog‘liqliklar tafsilotisiz 200 bilan {"ok":true} yoki 503 bilan {"ok":false} qaytaradi. Muvaffaqiyatli healthz SES ishlayotganini, hodisa iste’molchisi sog‘lomligini yoki yuborishga ruxsat borligini tasdiqlamaydi. ## OpenAPI, Postman va misollar Ish jarayoningizda mashina o‘qiydigan API tavsifi va tayyor HTTP misollaridan foydalaning. - OpenAPI: /docs/email-api/openapi.json — metodlar, sarlavhalar, so‘rov sxemalari, javoblar va xatolar. - Postman: /docs/email-api/postman.json — yuborish, holatni olish va healthz uchun kolleksiya. baseUrl va API kalitini lokal sozlang; sir saqlangan muhitni e’lon qilmang. - Yuqoridagi cURL, Node.js, Python va PHP misollarini nusxalang yoki hujjatlar bilan yuklab oling. Spetsifikatsiya statik hujjat fayli sifatida joylangan. Production API jarayoni /openapi.json, Swagger UI yoki interaktiv konsol bermaydi. Kolleksiyani import qilish yuborishni faollashtirmaydi va loyiha yaratmaydi. Yuborish so‘rovini bajarishdan oldin kirish huquqi tasdig‘ini kuting. Alohida e’lon qilingan Mailcore SDK paketlari talab qilinmaydi: integratsiya standart HTTPS va JSON’dan foydalanadi. Misol API xatti-harakatidan farq qilsa, HTTP holati va error’ni saqlang, hujjat sanasini tekshiring va sirlarni bermasdan yordamga murojaat qiling.