Documentation
DocumentationEmail API

MAILCORE FOR DEVELOPERS

Email API — developer documentation

Connect Mailcore Email API, send transactional email, handle idempotency and delivery states, and verify webhook signatures.

HTTP API v1 · 14 October 2026OpenAPIPostman

What Email API does

Send expected service messages from your application: order confirmations, receipts, password resets, login alerts and booking notifications.

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.

ProductPurposeSetup
Corporate EmailEmployee mailboxes, inbound mail, webmail and person-to-person correspondenceMailboxes, payment and mail-domain configuration
Email APIService messages generated by your applicationProject, 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.

Base URL and version
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.

Authentication and key storage

Sending and status endpoints use the Bearer API key for your project.

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

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.

Send with cURL
#!/usr/bin/env sh
set -eu

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

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

POST /emails reference

Create one email for one recipient. The request body is JSON; unknown fields are rejected.

FieldType and requirementRules
fromString, required3–320 characters. An active sender address in your project on a verified domain.
toString, required3–320 characters; exactly one valid address. Arrays and address lists are unsupported.
subjectString, required1–998 characters; no CR, LF, NUL or other control characters except tabs.
textString or null, optionalPlain-text content. At least text or html must be present; their combined size cannot exceed 200,000 UTF-8 bytes.
htmlString or null, optionalHTML content. Shares the 200,000-byte limit with text; account for multibyte characters.
replyToString or null, optionalOne valid address, 3–320 characters. Controls replies, not authorization of the sender.
tagsObject with string values, optionalUp 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 <orders@shop.example>. 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.

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.

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

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

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

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


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


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


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

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

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.

Retrieve email state
#!/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 — example state
{
  "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"
}
StatusMeaningApplication action
queuedThe request is committed and waiting for processingSave id and wait; do not create a copy
sendingThe worker has started handing it to the providerWait for the outcome
acceptedSES accepted the email and returned a provider IDWait for delivery evidence; this is not delivery
delayedThe provider reported a delivery delayContinue observing; do not automatically send another copy
deliveredThe provider reported delivery to the receiving mail serverRecord delivery; this does not prove inbox placement or a read
bouncedThe provider reported a delivery failureReview the reason; a permanent bounce suppresses future sends to the address
complainedA complaint was receivedStop sending to the recipient and review the use case
rejectedRejected by the provider or canceled before transmission after an authorization changeInspect detail and address the cause
failedProcessing ended in an errorInspect detail; this is not the same as a retryable HTTP error
ambiguousThe email may have been accepted, but the outcome is uncertainDo 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.

RuleBehavior
Format16–128 characters: A–Z, a–z, 0–9, period, underscore, colon or hyphen
ScopeOne API key and POST /emails; another key does not share the replay history
Same data and same keyReturns the saved 202 with the original id and status: queued, plus Idempotency-Replayed: true
Different data and same key409 idempotency_conflict; no new email is created
QuotasReplaying 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.

Errors and response handling

Check the HTTP status first, then the error field. Error responses do not echo your addresses or message content.

Error format — example
{
  "ok": false,
  "error": "quota_exceeded",
  "message": "api_key hour quota exceeded",
  "retryable": true
}
HTTPerrorAction
400idempotency_key_requiredSupply a valid Idempotency-Key; missing and invalid values use the same error
400 / 422request_invalidCheck JSON, types, addresses, required fields and limits; invalid Content-Length can also produce 400
401authentication_requiredCheck the Bearer key, expiration and project access; retrying without an access change usually does not help
403account_pausedSending is paused or permissions changed; contact support
403volume_anomaly_pausedA volume surge triggered a pause; stop automatic sending and request review
404email_not_foundCheck the ID and project; missing and other-project emails are indistinguishable
409idempotency_conflictRestore the original operation data, or use a new key for an intentionally new email
413request_too_largeReduce the complete HTTP body
422sender_not_allowedUse an active authorized address with the correct sender display name
422recipient_suppressedDo not retry to this recipient; investigate a permanent bounce or complaint
429quota_exceededSlow the flow, wait for a new window or agree on different limits
503sending_disabledProduction sending is off; wait for confirmed activation
503storage_capacity_guardPause 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.

LimitCurrent contract
RecipientsOne address per request
Contenttext + html: at most 200,000 UTF-8 bytes
HTTP bodyAt most 256 KiB including JSON
TagsUp to 10 pairs; allowed ASCII characters only
Attachments / CC / BCCUnsupported
Volume quotasSeparate hour/day/month limits for the key, project and organization
Public SLA / pricingAgreed 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.

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

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.

Signature headers
Webhook-Id: evt_0123456789abcdef01234567
Webhook-Timestamp: 1791964800
Webhook-Signature: v1,<hex HMAC-SHA256>
Exact signed bytes
<Webhook-Id>.<Webhook-Timestamp>.<raw HTTP body bytes>

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.

Webhook handlers

These examples verify signatures and persist events in a durable SQLite inbox before acknowledging receipt. Connect your business-processing worker before production.

ReceiverRequirements
Node.jsNode.js 22.13+; built-in node:sqlite, no additional packages
PythonPython 3.10+; standard library and SQLite
PHPPHP 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.

Example event
{
  "id": "evt_0123456789abcdef01234567",
  "type": "email.delivered",
  "createdAt": "2026-10-14T08:00:02.000Z",
  "data": {
    "emailId": "eml_0123456789abcdef01234567",
    "status": "delivered",
    "detail": "ses:delivery"
  }
}
Node.js: raw body and HMAC
// Node.js 22.13+; only built-in modules. Local receiver for integration testing.
// Put behind public HTTPS:443 in production; register it through the operator.
// Use a persistent inbox volume. The unique ID survives restarts; an application
// worker must apply business changes transactionally/idempotently from this
// inbox. Receipt here does NOT mean a business action has been performed.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';

const EVENT_STATUSES = new Set(['accepted', 'delayed', 'delivered', 'bounced', 'complained', 'rejected', 'failed', 'ambiguous']);
const MAX_BODY = 65_536;

export function verifyWebhook(rawBody, headers, secret, { now = Math.floor(Date.now() / 1000), toleranceSeconds = 300 } = {}) {
  if (!Buffer.isBuffer(rawBody) || rawBody.length > MAX_BODY || typeof secret !== 'string' || !secret) {
    throw new Error('Invalid webhook input');
  }
  const normalized = new Map();
  for (const [name, value] of Object.entries(headers)) {
    const key = name.toLowerCase();
    if (!key.startsWith('webhook-')) continue;
    if (normalized.has(key) || (Array.isArray(value) && value.length !== 1)) throw new Error('Duplicate header');
    const text = Array.isArray(value) ? value[0] : value;
    if (typeof text !== 'string') throw new Error('Invalid header');
    normalized.set(key, text);
  }
  const id = normalized.get('webhook-id') ?? '';
  const timestamp = normalized.get('webhook-timestamp') ?? '';
  const signature = normalized.get('webhook-signature') ?? '';
  if (!/^evt_[a-f0-9]{24}$/.test(id) || !/^[0-9]+$/.test(timestamp) || !/^v1,[a-f0-9]{64}$/.test(signature)) {
    throw new Error('Invalid signed headers');
  }
  const seconds = Number(timestamp);
  if (!Number.isSafeInteger(seconds) || !Number.isFinite(now) || !Number.isFinite(toleranceSeconds) || toleranceSeconds < 0 || Math.abs(now - seconds) > toleranceSeconds) {
    throw new Error('Timestamp outside tolerance');
  }
  // Sign the raw bytes BEFORE parsing JSON. The literal secret is UTF-8 text,
  // not a base64-decoded key. Never stringify a parsed object for verification.
  const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
    .update(Buffer.from(`${id}.${timestamp}.`, 'ascii')).update(rawBody).digest();
  if (!timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'))) throw new Error('Invalid signature');
  const event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
  if (!event || typeof event !== 'object' || Array.isArray(event) || event.id !== id || typeof event.createdAt !== 'string' || Number.isNaN(Date.parse(event.createdAt))) {
    throw new Error('Invalid event');
  }
  const data = event.data;
  if (!data || typeof data !== 'object' || Array.isArray(data) || !/^eml_[a-f0-9]{24}$/.test(data.emailId ?? '') || !EVENT_STATUSES.has(data.status) || event.type !== `email.${data.status}` || !(data.detail === null || typeof data.detail === 'string')) {
    throw new Error('Invalid event data');
  }
  return event;
}

export function startReceiver() {
  const secret = process.env.MAILCORE_WEBHOOK_SECRET;
  if (!secret) throw new Error('Set MAILCORE_WEBHOOK_SECRET; never log it');
  const port = Number(process.env.PORT ?? 8080);
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT');
  const inbox = new DatabaseSync(process.env.MAILCORE_WEBHOOK_INBOX ?? 'mailcore-webhook-inbox.sqlite3');
  inbox.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);');
  const insert = inbox.prepare('INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (?, ?)');
  const existing = inbox.prepare('SELECT raw_body FROM inbox WHERE id = ?');
  const server = createServer((req, res) => {
    const reply = (status, error) => {
      if (res.writableEnded) return;
      res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
      res.end(JSON.stringify(error ? { ok: false, error } : { ok: true }));
    };
    if (req.url !== '/mailcore-webhook') { req.resume(); reply(404, 'not_found'); return; }
    if (req.method !== 'POST') { req.resume(); res.setHeader('Allow', 'POST'); reply(405, 'method_not_allowed'); return; }
    if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
      req.resume(); reply(415, 'content_type_required'); return;
    }
    const chunks = [];
    let length = 0;
    let rejected = false;
    req.on('data', chunk => {
      length += chunk.length;
      if (length > MAX_BODY) { rejected = true; chunks.length = 0; reply(413, 'body_too_large'); return; }
      if (!rejected) chunks.push(chunk);
    });
    req.on('error', () => reply(400, 'request_invalid'));
    req.on('end', () => {
      if (rejected) return;
      const rawBody = Buffer.concat(chunks);
      let event;
      try { event = verifyWebhook(rawBody, req.headersDistinct, secret); }
      catch { reply(401, 'webhook_invalid'); return; }
      try {
        // SQLite commits the unique inbox row before we acknowledge. Retain
        // IDs for your retry/recovery policy and monitor unprocessed backlog.
        insert.run(event.id, rawBody);
        if (!Buffer.from(existing.get(event.id).raw_body).equals(rawBody)) {
          reply(409, 'event_id_conflict'); return;
        }
        reply(200);
      } catch { reply(500, 'inbox_unavailable'); }
    });
  });
  server.requestTimeout = 10_000;
  server.headersTimeout = 10_000;
  server.listen(port, '127.0.0.1');
  return server;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) startReceiver();
Python: signature verification
"""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: signature verification
<?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'); }
}

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.

Download

Import the contract into your development tools. Examples are plain code, not an official SDK.

OpenAPI 3.1Postman CollectionDocumentation for AI tools