Hujjatlar
HujjatlarEmail API

DASTURCHILAR UCHUN MAILCORE

Email API — dasturchilar uchun hujjatlar

Mailcore Email API’ni ulash, tranzaksion xatlar yuborish, idempotentlik va yetkazilish holatlarini boshqarish hamda webhook imzosini tekshirish.

HTTP API v1 · 2026-yil 14-oktabrOpenAPIPostman

Email API nima qiladi

Ilovangizdan kutilgan xizmat xatlarini yuboring: buyurtma tasdig‘i, chek, parolni tiklash, tizimga kirish va band qilish haqidagi bildirishnomalar.

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.

MahsulotVazifasiUlanish
Korporativ pochtaXodimlar pochta qutilari, kiruvchi pochta, veb-pochta va odamlar o‘rtasidagi yozishmalarPochta qutilari, to‘lov va pochta domenini sozlash
Email APIIlova yaratadigan xizmat xatlariLoyiha, 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.

Asosiy manzil va versiya
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.

Autentifikatsiya va kalitni saqlash

Yuborish va holatni o‘qish metodlari loyihangizning Bearer API kalitidan foydalanadi.

HTTP
Authorization: Bearer <MAILCORE_API_KEY>
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.

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.

cURL orqali yuborish
Yuklab olish
#!/usr/bin/env sh
set -eu

# Run only after operator approval. Replace the .example addresses below with
# your approved sender and intended recipient. Keep the key on your server.
# Sending is disabled at this docs release; production activation is separate.
# Save MAILCORE_IDEMPOTENCY_KEY with this order/event before the first attempt;
# reuse it and the unchanged payload after an uncertain HTTP response.
# This example performs one attempt. A queued/accepted result is not delivery;
# never blindly send an ambiguous message again under a new key.
: "${MAILCORE_API_KEY:?Set the operator-issued server key}"
: "${MAILCORE_IDEMPOTENCY_KEY:?Set the key persisted with this application event}"
case "$MAILCORE_IDEMPOTENCY_KEY" in
  *[!A-Za-z0-9._:-]*) printf '%s\n' 'Invalid idempotency key characters' >&2; exit 1 ;;
esac
if [ "${#MAILCORE_IDEMPOTENCY_KEY}" -lt 16 ] || [ "${#MAILCORE_IDEMPOTENCY_KEY}" -gt 128 ]; then
  printf '%s\n' 'Idempotency key must contain 16–128 characters' >&2
  exit 1
fi

curl --silent --show-error --fail-with-body \
  --connect-timeout 10 --max-time 30 \
  https://mailcore.uz/api/email/v1/emails \
  -H "Authorization: Bearer ${MAILCORE_API_KEY}" \
  -H "Idempotency-Key: ${MAILCORE_IDEMPOTENCY_KEY}" \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "from": "Example Shop <orders@notify.example>",
    "to": "customer@recipient.example",
    "subject": "Order #8421 completed",
    "text": "Your order has been completed.",
    "tags": {"order_id": "8421"}
  }'
202 Accepted — javob misoli
{
  "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.

POST /emails ma’lumotnomasi

Metod bitta oluvchi uchun bitta xat yaratadi. So‘rov tanasi JSON formatida; noma’lum maydonlar rad etiladi.

MaydonTuri va majburiyligiQoidalar
fromSatr, majburiy3–320 belgi. Loyihangizdagi tasdiqlangan domenning faol jo‘natuvchi manzili.
toSatr, majburiy3–320 belgi; aynan bitta to‘g‘ri manzil. Massiv va manzillar ro‘yxati qo‘llanmaydi.
subjectSatr, majburiy1–998 belgi; CR, LF, NUL va tabulyatsiyadan boshqa boshqaruv belgilari taqiqlangan.
textSatr yoki null, ixtiyoriyOddiy matn. text yoki html’dan kamida biri kerak; ikkalasi birgalikda 200 000 UTF-8 baytdan oshmasligi kerak.
htmlSatr yoki null, ixtiyoriyHTML ko‘rinish. 200 000 bayt limiti text bilan umumiy; ko‘p baytli belgilarni hisobga oling.
replyToSatr yoki null, ixtiyoriyBitta to‘g‘ri manzil, 3–320 belgi. Javob manzilini boshqaradi, jo‘natuvchiga ruxsat bermaydi.
tagsQiymatlari satr bo‘lgan obyekt, ixtiyoriyKo‘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 <orders@shop.example>. 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.

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.

MisolTalablar
Node.jsNode.js 18+; o‘rnatilgan fetch, qo‘shimcha paketlarsiz
PythonPython 3.10+; standart kutubxona
PHPHTTPS 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
Yuklab olish
// Node.js 18+, no packages. Run after approval with server environment secrets.
// Sending is disabled at this docs release; production activation is separate.
// Persist the idempotency key WITH the business event before the first attempt.
// This makes one request; on an uncertain HTTP result reuse the unchanged body
// and key. Never resend an ambiguous message under a fresh key.
const apiKey = process.env.MAILCORE_API_KEY;
const idempotencyKey = process.env.MAILCORE_IDEMPOTENCY_KEY;
if (!apiKey || /[\r\n]/.test(apiKey)) throw new Error('Set MAILCORE_API_KEY on your server');
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(idempotencyKey ?? '')) {
  throw new Error('Set the 16–128 character key saved with this application event');
}
const request = {
  from: process.env.MAILCORE_FROM ?? 'Example Shop <orders@notify.example>',
  to: process.env.MAILCORE_TO ?? 'customer@recipient.example',
  subject: 'Order #8421 completed',
  text: 'Your order has been completed.',
  tags: { order_id: '8421' },
};
if (request.from.includes('.example') || request.to.endsWith('.example')) {
  throw new Error('Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the intended recipient');
}
try {
  const response = await fetch('https://mailcore.uz/api/email/v1/emails', {
    method: 'POST',
    redirect: 'error',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Idempotency-Key': idempotencyKey,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(request),
    signal: AbortSignal.timeout(30_000),
  });
  const result = await response.json();
  if (response.status !== 202) {
    console.error(JSON.stringify({ httpStatus: response.status, ...result }));
    process.exitCode = 1;
  } else {
    // Persist result.id on the same business event; poll GET /emails/{id}.
    console.log(JSON.stringify(result));
  }
} catch {
  // Do not log request bodies or keys. Acceptance may be uncertain after I/O.
  console.error('No usable HTTP result. Reconcile using the saved event key and unchanged payload.');
  process.exitCode = 1;
}
Python
Yuklab olish
"""Python 3.10+, standard library. One attempt, only after project approval.

Sending is disabled at this docs release; production activation is separate.

Persist MAILCORE_IDEMPOTENCY_KEY with the application event BEFORE sending.
Reuse that key and unchanged payload after an uncertain HTTP result. Save the
returned id with the event. Never blindly resend an ambiguous message.
"""

import json
import os
import re
import sys
import urllib.error
import urllib.request


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None  # Never forward a Bearer key to a redirect destination.


def main():
    api_key = os.environ.get("MAILCORE_API_KEY", "")
    key = os.environ.get("MAILCORE_IDEMPOTENCY_KEY", "")
    if not api_key or "\r" in api_key or "\n" in api_key:
        raise SystemExit("Set MAILCORE_API_KEY in your server environment")
    if not re.fullmatch(r"[A-Za-z0-9._:-]{16,128}", key):
        raise SystemExit("Set the idempotency key saved with this application event")
    payload = {
        "from": os.environ.get("MAILCORE_FROM", "Example Shop <orders@notify.example>"),
        "to": os.environ.get("MAILCORE_TO", "customer@recipient.example"),
        "subject": "Order #8421 completed",
        "text": "Your order has been completed.",
        "tags": {"order_id": "8421"},
    }
    if ".example" in payload["from"] or payload["to"].endswith(".example"):
        raise SystemExit("Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the recipient")
    request = urllib.request.Request(
        "https://mailcore.uz/api/email/v1/emails",
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Authorization": "Bearer " + api_key,
            "Idempotency-Key": key,
            "Content-Type": "application/json",
        },
        method="POST",
    )
    try:
        with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
            result = json.loads(response.read(1_048_577))
            if response.status != 202:
                raise ValueError("unexpected HTTP status")
            print(json.dumps(result))  # Persist id on the business event.
    except urllib.error.HTTPError as error:
        try:
            detail = json.loads(error.read(1_048_577))
        except (ValueError, OSError):
            detail = {"error": "unexpected_http_response"}
        print(json.dumps({"httpStatus": error.code, **detail}), file=sys.stderr)
        raise SystemExit(1)
    except (urllib.error.URLError, TimeoutError, OSError, ValueError):
        raise SystemExit("No usable HTTP result. Reconcile with the saved event key and unchanged payload.")


if __name__ == "__main__":
    main()
PHP
Yuklab olish
<?php
// PHP 8.1+ with HTTPS stream support. No Composer packages.
// Sending is disabled at this docs release; production activation is separate.
// Persist this idempotency key with the application event before sending.
// Reuse key + unchanged payload after an uncertain HTTP result. Save the
// returned id on the same event. Never resend an ambiguous message blindly.
declare(strict_types=1);

$apiKey = getenv('MAILCORE_API_KEY') ?: '';
$key = getenv('MAILCORE_IDEMPOTENCY_KEY') ?: '';
if ($apiKey === '' || preg_match('/[\r\n]/', $apiKey)) {
    fwrite(STDERR, "Set MAILCORE_API_KEY on your server\n");
    exit(1);
}
if (!preg_match('/^[A-Za-z0-9._:-]{16,128}$/D', $key)) {
    fwrite(STDERR, "Set the idempotency key saved with this application event\n");
    exit(1);
}
$payload = [
    'from' => getenv('MAILCORE_FROM') ?: 'Example Shop <orders@notify.example>',
    'to' => getenv('MAILCORE_TO') ?: 'customer@recipient.example',
    'subject' => 'Order #8421 completed',
    'text' => 'Your order has been completed.',
    'tags' => ['order_id' => '8421'],
];
if (str_contains($payload['from'], '.example') || str_ends_with($payload['to'], '.example')) {
    fwrite(STDERR, "Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the recipient\n");
    exit(1);
}
$context = stream_context_create(['http' => [
    'method' => 'POST',
    'header' => "Authorization: Bearer {$apiKey}\r\nIdempotency-Key: {$key}\r\nContent-Type: application/json\r\n",
    'content' => json_encode($payload, JSON_THROW_ON_ERROR),
    'timeout' => 30,
    'ignore_errors' => true,
    'follow_location' => 0, // Do not forward the Bearer key on redirects.
], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$result = @file_get_contents('https://mailcore.uz/api/email/v1/emails', false, $context, 0, 1048577);
if ($result === false) {
    fwrite(STDERR, "No usable HTTP result. Reconcile with the saved event key and unchanged payload.\n");
    exit(1);
}
$status = 0;
foreach ($http_response_header ?? [] as $header) {
    if (preg_match('/^HTTP\/\S+ (\d{3})/', $header, $match)) $status = (int) $match[1];
}
try {
    $document = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($document)) throw new RuntimeException('Unexpected response');
    if ($status !== 202) {
        fwrite(STDERR, json_encode(['httpStatus' => $status] + $document, JSON_THROW_ON_ERROR) . "\n");
        exit(1);
    }
    echo json_encode($document, JSON_THROW_ON_ERROR) . "\n";
} catch (Throwable $error) {
    fwrite(STDERR, "No usable JSON result. Reconcile with the saved event key and unchanged payload.\n");
    exit(1);
}

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

Xat holatini olish
Yuklab olish
#!/usr/bin/env sh
set -eu
# Use the id returned by POST /emails. No recipient or body is returned.
# accepted means provider handoff. Do not resend an ambiguous message blindly.
: "${MAILCORE_API_KEY:?Set the operator-issued server key}"
: "${MAILCORE_EMAIL_ID:?Set the returned eml_ message ID}"
case "$MAILCORE_EMAIL_ID" in
  eml_*) mailcore_id_hex="${MAILCORE_EMAIL_ID#eml_}" ;;
  *) printf '%s\n' 'MAILCORE_EMAIL_ID must be a returned eml_ message ID' >&2; exit 1 ;;
esac
case "$mailcore_id_hex" in
  *[!a-f0-9]*) printf '%s\n' 'Invalid message ID' >&2; exit 1 ;;
esac
if [ "${#mailcore_id_hex}" -ne 24 ]; then
  printf '%s\n' 'Message ID must contain eml_ followed by 24 lowercase hex digits' >&2
  exit 1
fi
curl --silent --show-error --fail-with-body \
  --connect-timeout 10 --max-time 30 \
  "https://mailcore.uz/api/email/v1/emails/${MAILCORE_EMAIL_ID}" \
  -H "Authorization: Bearer ${MAILCORE_API_KEY}"
200 OK — holat misoli
{
  "id": "eml_0123456789abcdef01234567",
  "status": "delivered",
  "from": "Example Shop <orders@shop.example>",
  "providerMessageId": "example-provider-message-id",
  "detail": "ses:delivery",
  "createdAt": "2026-10-14T08:00:00.000Z",
  "updatedAt": "2026-10-14T08:00:02.000Z"
}
HolatMa’nosiIlova harakati
queuedSo‘rov saqlangan va qayta ishlanishni kutmoqdaid’ni saqlab, kuting; nusxa yaratmang
sendingIshchi jarayon provayderga uzatishni boshlaganNatijani kuting
acceptedSES xatni qabul qilib, provayder id’sini qaytarganYetkazilish hodisasini kuting; bu hali yetkazilish emas
delayedProvayder yetkazilish kechikayotganini bildirganKuzatishni davom ettiring; avtomatik qayta yubormang
deliveredProvayder oluvchi pochta serveriga yetkazilganini bildirganYetkazilishni qayd eting; bu kiruvchi qutiga tushish yoki o‘qilganlik isboti emas
bouncedProvayder yetkazilish muvaffaqiyatsizligini bildirganSababni tekshiring; doimiy bounce manzilga keyingi yuborishlarni bloklaydi
complainedXat bo‘yicha shikoyat kelganOluvchiga yuborishni to‘xtating va foydalanish maqsadini tekshiring
rejectedProvayder rad etgan yoki ruxsat o‘zgargani uchun uzatishdan oldin bekor qilingandetail’ni tekshirib, sababni bartaraf eting
failedQayta ishlash xato bilan yakunlangandetail’ni tekshiring; bu retryable HTTP xatosi bilan bir xil emas
ambiguousXat qabul qilingan bo‘lishi mumkin, ammo natija noaniqKo‘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.

QoidaNatija
Format16–128 belgi: A–Z, a–z, 0–9, nuqta, pastki chiziq, ikki nuqta yoki defis
DoiraBitta API kaliti va POST /emails; boshqa kalit takrorlash tarixidan foydalanmaydi
Bir xil ma’lumot va bir xil kalitDastlabki id va status: queued bilan saqlangan 202 qaytadi; Idempotency-Replayed: true sarlavhasi qo‘shiladi
Boshqa ma’lumot va bir xil kalit409 idempotency_conflict; yangi xat yaratilmaydi
KvotalarSaqlangan 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.

Xatolar va javoblarni qayta ishlash

Avval HTTP holatini, keyin error maydonini tekshiring. Xato javobi manzillar yoki xat mazmunini qaytarmaydi.

Xato formati — misol
{
  "ok": false,
  "error": "quota_exceeded",
  "message": "api_key hour quota exceeded",
  "retryable": true
}
HTTPerrorNima qilish kerak
400idempotency_key_requiredTo‘g‘ri Idempotency-Key kiriting; yo‘q va noto‘g‘ri qiymatlar uchun bir xil xato
400 / 422request_invalidJSON, turlar, manzillar, majburiy maydonlar va limitlarni tekshiring; noto‘g‘ri Content-Length ham 400 berishi mumkin
401authentication_requiredBearer kaliti, amal qilish muddati va loyiha huquqlarini tekshiring; huquq o‘zgarmasdan takrorlash odatda yordam bermaydi
403account_pausedYuborish to‘xtatilgan yoki ruxsatlar o‘zgargan; yordam xizmatiga murojaat qiling
403volume_anomaly_pausedHajmning keskin oshishi to‘xtatishga sabab bo‘lgan; avtomatik yuborishni to‘xtatib, tekshiruvni kelishing
404email_not_foundid va loyihani tekshiring; yo‘q va boshqa loyihadagi xatlar farqlanmaydi
409idempotency_conflictDastlabki amal ma’lumotlarini tiklang yoki ataylab yangi xat uchun yangi kalit tanlang
413request_too_largeTo‘liq HTTP tanasini kichraytiring
422sender_not_allowedFaol, ruxsat etilgan manzil va to‘g‘ri jo‘natuvchi nomidan foydalaning
422recipient_suppressedBu oluvchiga qayta urinmang; doimiy bounce yoki shikoyatni tekshiring
429quota_exceededOqimni sekinlashtiring, yangi davrni kuting yoki limitlarni kelishing
503sending_disabledProduction yuborish o‘chirilgan; faollashtirish tasdig‘ini kuting
503storage_capacity_guardYangi 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.

CheklovHozirgi API
OluvchilarHar bir so‘rovda bitta manzil
Mazmuntext + html: ko‘pi bilan 200 000 UTF-8 bayt
HTTP tanasiJSON bilan birga ko‘pi bilan 256 KiB
Teglar10 juftlikkacha; faqat ruxsat etilgan ASCII belgilar
Fayl ilovalari / CC / BCCQo‘llanmaydi
Hajm kvotalariKalit, loyiha va tashkilot uchun alohida hour/day/month limitlari
Ommaviy SLA / narxlarTekshiruvda 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.

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

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.

Imzo sarlavhalari
Webhook-Id: evt_0123456789abcdef01234567
Webhook-Timestamp: 1791964800
Webhook-Signature: v1,<hex HMAC-SHA256>
Imzolanadigan aniq baytlar
<Webhook-Id>.<Webhook-Timestamp>.<asl HTTP tanasi baytlari>

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.

Webhook ishlov beruvchilari

Misollar imzoni tekshiradi va tasdiqlashdan oldin hodisani doimiy SQLite inbox’ga saqlaydi. Production’dan oldin biznes mantiqingiz ishlov beruvchisini ulang.

Qabul qiluvchiTalablar
Node.jsNode.js 22.13+; o‘rnatilgan node:sqlite, qo‘shimcha paketlarsiz
PythonPython 3.10+; standart kutubxona va SQLite
PHPPHP 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.

Hodisa misoli
{
  "id": "evt_0123456789abcdef01234567",
  "type": "email.delivered",
  "createdAt": "2026-10-14T08:00:02.000Z",
  "data": {
    "emailId": "eml_0123456789abcdef01234567",
    "status": "delivered",
    "detail": "ses:delivery"
  }
}
Node.js: asl tana va HMAC
Yuklab olish
// 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: imzoni tekshirish
Yuklab olish
"""Python 3.10+, standard library. Local receiver; production needs HTTPS:443.

An operator configures the webhook. Persist the SQLite inbox on durable storage.
An application worker must apply business changes transactionally/idempotently
from this inbox. HTTP 200 acknowledges durable receipt, not a business action.
"""
import hashlib
import hmac
from contextlib import closing
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import math
import os
import re
import sqlite3
import time

MAX_BODY = 65_536
EVENT_STATUSES = {"accepted", "delayed", "delivered", "bounced", "complained", "rejected", "failed", "ambiguous"}


def verify_webhook(raw_body, headers, secret, *, now=None, tolerance_seconds=300):
    if not isinstance(raw_body, bytes) or len(raw_body) > MAX_BODY or not isinstance(secret, str) or not secret:
        raise ValueError("Invalid webhook input")
    normalized = {}
    for name, value in headers.items():
        key = name.lower()
        if not key.startswith("webhook-"):
            continue
        if key in normalized or (isinstance(value, (list, tuple)) and len(value) != 1):
            raise ValueError("Duplicate header")
        text = value[0] if isinstance(value, (list, tuple)) else value
        if not isinstance(text, str):
            raise ValueError("Invalid header")
        normalized[key] = text
    event_id = normalized.get("webhook-id", "")
    timestamp = normalized.get("webhook-timestamp", "")
    signature = normalized.get("webhook-signature", "")
    if not re.fullmatch(r"evt_[a-f0-9]{24}", event_id) or not re.fullmatch(r"[0-9]{1,13}", timestamp) or not re.fullmatch(r"v1,[a-f0-9]{64}", signature):
        raise ValueError("Invalid signed headers")
    current = time.time() if now is None else now
    if not math.isfinite(current) or not math.isfinite(tolerance_seconds) or tolerance_seconds < 0 or abs(current - int(timestamp)) > tolerance_seconds:
        raise ValueError("Timestamp outside tolerance")
    # Exact raw request bytes. The literal secret is UTF-8, not base64-decoded.
    signed = (event_id + "." + timestamp + ".").encode("ascii") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature[3:]):
        raise ValueError("Invalid signature")
    event = json.loads(raw_body.decode("utf-8"))
    if not isinstance(event, dict) or event.get("id") != event_id or not isinstance(event.get("createdAt"), str):
        raise ValueError("Invalid event")
    data = event.get("data")
    if not isinstance(data, dict) or not isinstance(data.get("emailId"), str) or not re.fullmatch(r"eml_[a-f0-9]{24}", data["emailId"]):
        raise ValueError("Invalid email ID")
    status = data.get("status")
    if not isinstance(status, str) or status not in EVENT_STATUSES or event.get("type") != "email." + status or "detail" not in data or not (data["detail"] is None or isinstance(data["detail"], str)):
        raise ValueError("Invalid event data")
    return event


def main():
    secret = os.environ.get("MAILCORE_WEBHOOK_SECRET", "")
    if not secret:
        raise SystemExit("Set MAILCORE_WEBHOOK_SECRET; never log it")
    inbox_path = os.environ.get("MAILCORE_WEBHOOK_INBOX", "mailcore-webhook-inbox.sqlite3")
    with closing(sqlite3.connect(inbox_path)) as connection, connection:
        connection.execute("PRAGMA journal_mode=WAL")
        connection.execute("PRAGMA synchronous=FULL")
        connection.execute("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)")

    class Receiver(BaseHTTPRequestHandler):
        # Quiet by default: never log bodies, tokens or webhook secrets.
        def log_message(self, format, *args):
            pass

        def respond(self, status, error=None):
            body = json.dumps({"ok": not bool(error), **({"error": error} if error else {})}).encode()
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Connection", "close")
            if status == 405:
                self.send_header("Allow", "POST")
            self.end_headers()
            if self.command != "HEAD":
                self.wfile.write(body)
            self.close_connection = True

        def do_POST(self):
            self.connection.settimeout(10)
            if self.path != "/mailcore-webhook":
                self.respond(404, "not_found")
                return
            if self.headers.get("Content-Type", "").split(";")[0].strip().lower() != "application/json":
                self.respond(415, "content_type_required")
                return
            # This small demo does not parse chunked transfer coding. A reverse
            # proxy must buffer/dechunk and supply one Content-Length header.
            lengths = self.headers.get_all("Content-Length", [])
            if self.headers.get("Transfer-Encoding") or len(lengths) != 1 or not re.fullmatch(r"[0-9]{1,10}", lengths[0]):
                self.respond(400, "content_length_required")
                return
            size = int(lengths[0])
            if size > MAX_BODY:
                self.respond(413, "body_too_large")
                return
            try:
                raw_body = self.rfile.read(size)
                if len(raw_body) != size:
                    self.respond(400, "request_invalid")
                    return
                signed_headers = {name: self.headers.get_all(name, []) for name in ("Webhook-Id", "Webhook-Timestamp", "Webhook-Signature")}
                event = verify_webhook(raw_body, signed_headers, secret)
            except (ValueError, UnicodeError, OSError, TypeError):
                self.respond(401, "webhook_invalid")
                return
            try:
                with closing(sqlite3.connect(inbox_path)) as connection, connection:
                    connection.execute("PRAGMA synchronous=FULL")
                    connection.execute("INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (?, ?)", (event["id"], raw_body))
                    saved = connection.execute("SELECT raw_body FROM inbox WHERE id = ?", (event["id"],)).fetchone()[0]
                    if saved != raw_body:
                        self.respond(409, "event_id_conflict")
                        return
                # Transaction has committed. A repeat now safely acknowledges
                # the persisted ID; process the inbox separately and monitor it.
                self.respond(200)
            except sqlite3.Error:
                self.respond(500, "inbox_unavailable")

        def do_GET(self):
            self.respond(405, "method_not_allowed")

        do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_HEAD = do_TRACE = do_GET

    port = int(os.environ.get("PORT", "8080"))
    HTTPServer(("127.0.0.1", port), Receiver).serve_forever()


if __name__ == "__main__":
    main()
PHP: imzoni tekshirish
Yuklab olish
<?php
// PHP 8.1+ and the bundled SQLite3 extension; no Composer packages.
// Local demo: php -S 127.0.0.1:8080 webhook-php.php
// Production needs public HTTPS:443 plus operator registration and durable DB.
// Inbox receipt is durable. A worker must apply business actions from the inbox
// transactionally/idempotently; this handler does not perform those actions.
declare(strict_types=1);

function verifyWebhook(string $rawBody, array $headers, string $secret, ?int $now = null, int $toleranceSeconds = 300): array {
    if ($secret === '' || strlen($rawBody) > 65536) throw new RuntimeException('Invalid input');
    $normalized = [];
    foreach ($headers as $name => $value) {
        $key = strtolower($name);
        if (!str_starts_with($key, 'webhook-')) continue;
        if (array_key_exists($key, $normalized) || (is_array($value) && count($value) !== 1)) throw new RuntimeException('Duplicate header');
        $value = is_array($value) ? array_values($value)[0] : $value;
        if (!is_string($value)) throw new RuntimeException('Invalid header');
        $normalized[$key] = $value;
    }
    $id = $normalized['webhook-id'] ?? '';
    $timestamp = $normalized['webhook-timestamp'] ?? '';
    $signature = $normalized['webhook-signature'] ?? '';
    if (!preg_match('/^evt_[a-f0-9]{24}$/D', $id) || !preg_match('/^[0-9]{1,13}$/D', $timestamp) || !preg_match('/^v1,[a-f0-9]{64}$/D', $signature)) throw new RuntimeException('Invalid signed headers');
    if ($toleranceSeconds < 0 || abs(($now ?? time()) - (int) $timestamp) > $toleranceSeconds) throw new RuntimeException('Stale timestamp');
    // Exact bytes BEFORE JSON parsing. Use the literal secret as UTF-8 text.
    $expected = hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $secret);
    if (!hash_equals($expected, substr($signature, 3))) throw new RuntimeException('Invalid signature');
    $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($event) || ($event['id'] ?? null) !== $id || !is_string($event['createdAt'] ?? null)) throw new RuntimeException('Invalid event');
    $data = $event['data'] ?? null;
    $statuses = ['accepted', 'delayed', 'delivered', 'bounced', 'complained', 'rejected', 'failed', 'ambiguous'];
    if (!is_array($data) || !is_string($data['emailId'] ?? null) || !preg_match('/^eml_[a-f0-9]{24}$/D', $data['emailId']) || !in_array($data['status'] ?? null, $statuses, true) || ($event['type'] ?? null) !== 'email.' . $data['status'] || !array_key_exists('detail', $data) || !($data['detail'] === null || is_string($data['detail']))) throw new RuntimeException('Invalid event data');
    return $event;
}

function respond(int $status, ?string $error = null): never {
    http_response_code($status);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    if ($status === 405) header('Allow: POST');
    echo json_encode($error === null ? ['ok' => true] : ['ok' => false, 'error' => $error], JSON_THROW_ON_ERROR);
    exit;
}

// Safe to require this file from a local verification test without serving.
if (isset($_SERVER['SCRIPT_FILENAME']) && realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) {
    if (($_SERVER['REQUEST_URI'] ?? '') !== '/mailcore-webhook') respond(404, 'not_found');
    if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') respond(405, 'method_not_allowed');
    if (strtolower(trim(explode(';', $_SERVER['CONTENT_TYPE'] ?? '')[0])) !== 'application/json') respond(415, 'content_type_required');
    // Buffer/dechunk and reject duplicate signed headers at the reverse proxy;
    // PHP SAPI can overwrite duplicate headers before the application sees them.
    $rawBody = file_get_contents('php://input', false, null, 0, 65537);
    if ($rawBody === false) respond(400, 'request_invalid');
    if (strlen($rawBody) > 65536) respond(413, 'body_too_large');
    $secret = getenv('MAILCORE_WEBHOOK_SECRET') ?: '';
    if ($secret === '') respond(500, 'receiver_not_configured');
    try {
        $event = verifyWebhook($rawBody, [
            'Webhook-Id' => $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
            'Webhook-Timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
            'Webhook-Signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
        ], $secret);
    } catch (Throwable $error) { respond(401, 'webhook_invalid'); }
    try {
        $inbox = new SQLite3(getenv('MAILCORE_WEBHOOK_INBOX') ?: 'mailcore-webhook-inbox.sqlite3');
        $inbox->enableExceptions(true);
        $inbox->busyTimeout(5000);
        $inbox->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);');
        $insert = $inbox->prepare('INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (:id, :body)');
        $insert->bindValue(':id', $event['id'], SQLITE3_TEXT);
        $insert->bindValue(':body', $rawBody, SQLITE3_BLOB);
        $insert->execute(); // Autocommit completes before acknowledging.
        $query = $inbox->prepare('SELECT raw_body FROM inbox WHERE id = :id');
        $query->bindValue(':id', $event['id'], SQLITE3_TEXT);
        if ($query->execute()->fetchArray(SQLITE3_ASSOC)['raw_body'] !== $rawBody) respond(409, 'event_id_conflict');
        $inbox->close();
        respond(200); // Already persisted duplicates also acknowledge safely.
    } catch (Throwable $error) { respond(500, 'inbox_unavailable'); }
}

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.

Yuklab olish

Kontraktni dasturlash vositalaringizga import qiling. Misollar oddiy kod bo‘lib, rasmiy SDK emas.

OpenAPI 3.1Postman CollectionAI vositalari uchun hujjatlar