MAILCORE FOR DEVELOPERS
Email API — developer documentation
Connect Mailcore Email API, send transactional email, handle idempotency and delivery states, and verify webhook signatures.
What Email API does
Send expected service messages from your application: order confirmations, receipts, password resets, login alerts and booking notifications.
Mailcore commits a message to a durable queue, hands it to Amazon SES, and updates its state from provider events. A 202 response confirms that the job was saved, not that the recipient received it. Store the email ID and follow the outcome using a GET request or signed webhooks.
| Product | Purpose | Setup |
|---|---|---|
| Corporate Email | Employee mailboxes, inbound mail, webmail and person-to-person correspondence | Mailboxes, payment and mail-domain configuration |
| Email API | Service messages generated by your application | Project, sending domain, authorized sender and an API key after review |
Email API is for transactional messages with a clear relationship between the recipient and your service. Cold outreach, purchased lists, bulk marketing and unrestricted mail relay are outside this contract. Do not promise inbox placement or a particular delivery time to your users: a delivery state does not establish that a message was read.
https://mailcore.uz/api/email/v1
POST /emails
GET /emails/{id}
GET /healthzAccess 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.
Authorization: Bearer <MAILCORE_API_KEY>
Content-Type: application/json
Idempotency-Key: order-8421-completedMAILCORE_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.
#!/usr/bin/env sh
set -eu
# Run only after operator approval. Replace the .example addresses below with
# your approved sender and intended recipient. Keep the key on your server.
# Sending is disabled at this docs release; production activation is separate.
# Save MAILCORE_IDEMPOTENCY_KEY with this order/event before the first attempt;
# reuse it and the unchanged payload after an uncertain HTTP response.
# This example performs one attempt. A queued/accepted result is not delivery;
# never blindly send an ambiguous message again under a new key.
: "${MAILCORE_API_KEY:?Set the operator-issued server key}"
: "${MAILCORE_IDEMPOTENCY_KEY:?Set the key persisted with this application event}"
case "$MAILCORE_IDEMPOTENCY_KEY" in
*[!A-Za-z0-9._:-]*) printf '%s\n' 'Invalid idempotency key characters' >&2; exit 1 ;;
esac
if [ "${#MAILCORE_IDEMPOTENCY_KEY}" -lt 16 ] || [ "${#MAILCORE_IDEMPOTENCY_KEY}" -gt 128 ]; then
printf '%s\n' 'Idempotency key must contain 16–128 characters' >&2
exit 1
fi
curl --silent --show-error --fail-with-body \
--connect-timeout 10 --max-time 30 \
https://mailcore.uz/api/email/v1/emails \
-H "Authorization: Bearer ${MAILCORE_API_KEY}" \
-H "Idempotency-Key: ${MAILCORE_IDEMPOTENCY_KEY}" \
-H 'Content-Type: application/json' \
--data-binary '{
"from": "Example Shop <orders@notify.example>",
"to": "customer@recipient.example",
"subject": "Order #8421 completed",
"text": "Your order has been completed.",
"tags": {"order_id": "8421"}
}'
{
"id": "eml_0123456789abcdef01234567",
"status": "queued"
}- 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.
| Field | Type and requirement | Rules |
|---|---|---|
| from | String, required | 3–320 characters. An active sender address in your project on a verified domain. |
| to | String, required | 3–320 characters; exactly one valid address. Arrays and address lists are unsupported. |
| subject | String, required | 1–998 characters; no CR, LF, NUL or other control characters except tabs. |
| text | String or null, optional | Plain-text content. At least text or html must be present; their combined size cannot exceed 200,000 UTF-8 bytes. |
| html | String or null, optional | HTML content. Shares the 200,000-byte limit with text; account for multibyte characters. |
| replyTo | String or null, optional | One valid address, 3–320 characters. Controls replies, not authorization of the sender. |
| tags | Object with string values, optional | Up to 10 pairs. Names: 1–64 characters; values: 1–256. Only A–Z, a–z, 0–9, _ and - are allowed. Names beginning with mailcore_ in any case are reserved. |
Addresses can include a display name of up to 128 characters, such as Example Shop <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.
| Example | Requirements |
|---|---|
| Node.js | Node.js 18+; built-in fetch, no additional packages |
| Python | Python 3.10+; standard library |
| PHP | PHP 8.1+ with HTTPS stream support; no Composer |
The scripts use MAILCORE_API_KEY and the saved MAILCORE_IDEMPOTENCY_KEY. Set MAILCORE_FROM and MAILCORE_TO for your addresses in Node.js, Python and PHP; edit the sample JSON before running cURL. MAILCORE_FROM must match your sender binding, not your website account address.
// Node.js 18+, no packages. Run after approval with server environment secrets.
// Sending is disabled at this docs release; production activation is separate.
// Persist the idempotency key WITH the business event before the first attempt.
// This makes one request; on an uncertain HTTP result reuse the unchanged body
// and key. Never resend an ambiguous message under a fresh key.
const apiKey = process.env.MAILCORE_API_KEY;
const idempotencyKey = process.env.MAILCORE_IDEMPOTENCY_KEY;
if (!apiKey || /[\r\n]/.test(apiKey)) throw new Error('Set MAILCORE_API_KEY on your server');
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(idempotencyKey ?? '')) {
throw new Error('Set the 16–128 character key saved with this application event');
}
const request = {
from: process.env.MAILCORE_FROM ?? 'Example Shop <orders@notify.example>',
to: process.env.MAILCORE_TO ?? 'customer@recipient.example',
subject: 'Order #8421 completed',
text: 'Your order has been completed.',
tags: { order_id: '8421' },
};
if (request.from.includes('.example') || request.to.endsWith('.example')) {
throw new Error('Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the intended recipient');
}
try {
const response = await fetch('https://mailcore.uz/api/email/v1/emails', {
method: 'POST',
redirect: 'error',
headers: {
Authorization: `Bearer ${apiKey}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(30_000),
});
const result = await response.json();
if (response.status !== 202) {
console.error(JSON.stringify({ httpStatus: response.status, ...result }));
process.exitCode = 1;
} else {
// Persist result.id on the same business event; poll GET /emails/{id}.
console.log(JSON.stringify(result));
}
} catch {
// Do not log request bodies or keys. Acceptance may be uncertain after I/O.
console.error('No usable HTTP result. Reconcile using the saved event key and unchanged payload.');
process.exitCode = 1;
}
"""Python 3.10+, standard library. One attempt, only after project approval.
Sending is disabled at this docs release; production activation is separate.
Persist MAILCORE_IDEMPOTENCY_KEY with the application event BEFORE sending.
Reuse that key and unchanged payload after an uncertain HTTP result. Save the
returned id with the event. Never blindly resend an ambiguous message.
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # Never forward a Bearer key to a redirect destination.
def main():
api_key = os.environ.get("MAILCORE_API_KEY", "")
key = os.environ.get("MAILCORE_IDEMPOTENCY_KEY", "")
if not api_key or "\r" in api_key or "\n" in api_key:
raise SystemExit("Set MAILCORE_API_KEY in your server environment")
if not re.fullmatch(r"[A-Za-z0-9._:-]{16,128}", key):
raise SystemExit("Set the idempotency key saved with this application event")
payload = {
"from": os.environ.get("MAILCORE_FROM", "Example Shop <orders@notify.example>"),
"to": os.environ.get("MAILCORE_TO", "customer@recipient.example"),
"subject": "Order #8421 completed",
"text": "Your order has been completed.",
"tags": {"order_id": "8421"},
}
if ".example" in payload["from"] or payload["to"].endswith(".example"):
raise SystemExit("Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the recipient")
request = urllib.request.Request(
"https://mailcore.uz/api/email/v1/emails",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": "Bearer " + api_key,
"Idempotency-Key": key,
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
result = json.loads(response.read(1_048_577))
if response.status != 202:
raise ValueError("unexpected HTTP status")
print(json.dumps(result)) # Persist id on the business event.
except urllib.error.HTTPError as error:
try:
detail = json.loads(error.read(1_048_577))
except (ValueError, OSError):
detail = {"error": "unexpected_http_response"}
print(json.dumps({"httpStatus": error.code, **detail}), file=sys.stderr)
raise SystemExit(1)
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
raise SystemExit("No usable HTTP result. Reconcile with the saved event key and unchanged payload.")
if __name__ == "__main__":
main()
<?php
// PHP 8.1+ with HTTPS stream support. No Composer packages.
// Sending is disabled at this docs release; production activation is separate.
// Persist this idempotency key with the application event before sending.
// Reuse key + unchanged payload after an uncertain HTTP result. Save the
// returned id on the same event. Never resend an ambiguous message blindly.
declare(strict_types=1);
$apiKey = getenv('MAILCORE_API_KEY') ?: '';
$key = getenv('MAILCORE_IDEMPOTENCY_KEY') ?: '';
if ($apiKey === '' || preg_match('/[\r\n]/', $apiKey)) {
fwrite(STDERR, "Set MAILCORE_API_KEY on your server\n");
exit(1);
}
if (!preg_match('/^[A-Za-z0-9._:-]{16,128}$/D', $key)) {
fwrite(STDERR, "Set the idempotency key saved with this application event\n");
exit(1);
}
$payload = [
'from' => getenv('MAILCORE_FROM') ?: 'Example Shop <orders@notify.example>',
'to' => getenv('MAILCORE_TO') ?: 'customer@recipient.example',
'subject' => 'Order #8421 completed',
'text' => 'Your order has been completed.',
'tags' => ['order_id' => '8421'],
];
if (str_contains($payload['from'], '.example') || str_ends_with($payload['to'], '.example')) {
fwrite(STDERR, "Set MAILCORE_FROM to your approved sender and MAILCORE_TO to the recipient\n");
exit(1);
}
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer {$apiKey}\r\nIdempotency-Key: {$key}\r\nContent-Type: application/json\r\n",
'content' => json_encode($payload, JSON_THROW_ON_ERROR),
'timeout' => 30,
'ignore_errors' => true,
'follow_location' => 0, // Do not forward the Bearer key on redirects.
], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
$result = @file_get_contents('https://mailcore.uz/api/email/v1/emails', false, $context, 0, 1048577);
if ($result === false) {
fwrite(STDERR, "No usable HTTP result. Reconcile with the saved event key and unchanged payload.\n");
exit(1);
}
$status = 0;
foreach ($http_response_header ?? [] as $header) {
if (preg_match('/^HTTP\/\S+ (\d{3})/', $header, $match)) $status = (int) $match[1];
}
try {
$document = json_decode($result, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($document)) throw new RuntimeException('Unexpected response');
if ($status !== 202) {
fwrite(STDERR, json_encode(['httpStatus' => $status] + $document, JSON_THROW_ON_ERROR) . "\n");
exit(1);
}
echo json_encode($document, JSON_THROW_ON_ERROR) . "\n";
} catch (Throwable $error) {
fwrite(STDERR, "No usable JSON result. Reconcile with the saved event key and unchanged payload.\n");
exit(1);
}
In production, use bounded timeouts, your application's queue and controlled retries. Do not send directly from a browser or record secrets and full payloads in error logs. A retry with a new Idempotency-Key is a new email even when its content matches.
Read an email and its status
GET /emails/{id} returns the current state of an email in the project associated with your API key.
#!/usr/bin/env sh
set -eu
# Use the id returned by POST /emails. No recipient or body is returned.
# accepted means provider handoff. Do not resend an ambiguous message blindly.
: "${MAILCORE_API_KEY:?Set the operator-issued server key}"
: "${MAILCORE_EMAIL_ID:?Set the returned eml_ message ID}"
case "$MAILCORE_EMAIL_ID" in
eml_*) mailcore_id_hex="${MAILCORE_EMAIL_ID#eml_}" ;;
*) printf '%s\n' 'MAILCORE_EMAIL_ID must be a returned eml_ message ID' >&2; exit 1 ;;
esac
case "$mailcore_id_hex" in
*[!a-f0-9]*) printf '%s\n' 'Invalid message ID' >&2; exit 1 ;;
esac
if [ "${#mailcore_id_hex}" -ne 24 ]; then
printf '%s\n' 'Message ID must contain eml_ followed by 24 lowercase hex digits' >&2
exit 1
fi
curl --silent --show-error --fail-with-body \
--connect-timeout 10 --max-time 30 \
"https://mailcore.uz/api/email/v1/emails/${MAILCORE_EMAIL_ID}" \
-H "Authorization: Bearer ${MAILCORE_API_KEY}"
{
"id": "eml_0123456789abcdef01234567",
"status": "delivered",
"from": "Example Shop <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"
}| Status | Meaning | Application action |
|---|---|---|
| queued | The request is committed and waiting for processing | Save id and wait; do not create a copy |
| sending | The worker has started handing it to the provider | Wait for the outcome |
| accepted | SES accepted the email and returned a provider ID | Wait for delivery evidence; this is not delivery |
| delayed | The provider reported a delivery delay | Continue observing; do not automatically send another copy |
| delivered | The provider reported delivery to the receiving mail server | Record delivery; this does not prove inbox placement or a read |
| bounced | The provider reported a delivery failure | Review the reason; a permanent bounce suppresses future sends to the address |
| complained | A complaint was received | Stop sending to the recipient and review the use case |
| rejected | Rejected by the provider or canceled before transmission after an authorization change | Inspect detail and address the cause |
| failed | Processing ended in an error | Inspect detail; this is not the same as a retryable HTTP error |
| ambiguous | The email may have been accepted, but the outcome is uncertain | Do not resend blindly; wait for events or investigate with support |
The response includes id, status, from, providerMessageId, detail, createdAt and updatedAt. providerMessageId and detail can be null. Timestamps use UTC. The response does not expose the recipient, subject or content. detail is diagnostic and may vary; do not rely on an exhaustive list of its strings for business logic.
An invalid ID, a missing email, a record removed under retention policy and an email in another project all return the same 404 email_not_found. Maintain your own required business-event history. You may not observe every intermediate state: a delivery event can arrive before your application sees accepted.
Idempotency and safe retries
Idempotency-Key is required for POST /emails. It links repeated HTTP requests to one committed result.
| Rule | Behavior |
|---|---|
| Format | 16–128 characters: A–Z, a–z, 0–9, period, underscore, colon or hyphen |
| Scope | One API key and POST /emails; another key does not share the replay history |
| Same data and same key | Returns the saved 202 with the original id and status: queued, plus Idempotency-Replayed: true |
| Different data and same key | 409 idempotency_conflict; no new email is created |
| Quotas | Replaying a committed request does not consume additional quota |
Choose a key from an immutable event, such as order-8421-completed. Save the final message data before sending, and reuse it on retries. Generating a new date, link, token or text during a retry can create a conflict. Use a new key for an intentionally new email.
If the connection is lost before the 202 response arrives, repeat the same data with the same key to recover the original id if the request committed. A replayed POST returns the initial queueing result, not the current delivery state; use GET or a webhook for the latter. Do not assume idempotency records are kept forever; agree on retention during setup.
Errors and response handling
Check the HTTP status first, then the error field. Error responses do not echo your addresses or message content.
{
"ok": false,
"error": "quota_exceeded",
"message": "api_key hour quota exceeded",
"retryable": true
}| HTTP | error | Action |
|---|---|---|
| 400 | idempotency_key_required | Supply a valid Idempotency-Key; missing and invalid values use the same error |
| 400 / 422 | request_invalid | Check JSON, types, addresses, required fields and limits; invalid Content-Length can also produce 400 |
| 401 | authentication_required | Check the Bearer key, expiration and project access; retrying without an access change usually does not help |
| 403 | account_paused | Sending is paused or permissions changed; contact support |
| 403 | volume_anomaly_paused | A volume surge triggered a pause; stop automatic sending and request review |
| 404 | email_not_found | Check the ID and project; missing and other-project emails are indistinguishable |
| 409 | idempotency_conflict | Restore the original operation data, or use a new key for an intentionally new email |
| 413 | request_too_large | Reduce the complete HTTP body |
| 422 | sender_not_allowed | Use an active authorized address with the correct sender display name |
| 422 | recipient_suppressed | Do not retry to this recipient; investigate a permanent bounce or complaint |
| 429 | quota_exceeded | Slow the flow, wait for a new window or agree on different limits |
| 503 | sending_disabled | Production sending is off; wait for confirmed activation |
| 503 | storage_capacity_guard | Pause new submissions temporarily and retry with delay after capacity is restored |
retryable indicates that a rejected API request may be repeated after a temporary condition is resolved. true does not promise that an immediate retry will work: sending_disabled needs activation; quota_exceeded needs a new window or an approved limit change. Use a retry limit, increasing delay and jitter.
retryable is not included in successful GET responses or webhook data. The failed status is not an instruction to resend. For an unknown proxy response, a 5xx or a network exception, preserve the original data and Idempotency-Key so repeating the request does not create a new operation. The API does not publish Retry-After or remaining-quota headers.
Quotas, limits and reputation
Limits are agreed during setup. There is no public promise of unlimited sending or a universal quota.
Hourly, daily and monthly quotas apply simultaneously to the key, project and organization. A new request committed to the queue consumes one unit in all three scopes; validation failures and idempotent replays do not consume quota. The windows are calendar periods in UTC: hour, day and month, not rolling periods from your first send.
| Limit | Current contract |
|---|---|
| Recipients | One address per request |
| Content | text + html: at most 200,000 UTF-8 bytes |
| HTTP body | At most 256 KiB including JSON |
| Tags | Up to 10 pairs; allowed ASCII characters only |
| Attachments / CC / BCC | Unsupported |
| Volume quotas | Separate hour/day/month limits for the key, project and organization |
| Public SLA / pricing | Agreed during review; not derived from mailbox pricing |
Permanent bounces and complaints add the recipient to your organization's suppression list. A new request to that address returns recipient_suppressed before quota is consumed. Do not bypass suppression by changing a key or project. Address the cause and discuss the next step with Mailcore.
A high permanent-bounce rate, complaints or an unusual volume surge can pause the organization for review. Increase volume gradually, send expected notifications and monitor template failures. Quota allows a message to enter the queue; it does not guarantee delivery. Receiving-provider limits and domain reputation apply separately.
Webhooks: events and signatures
Receive state changes without routine polling. During the pilot, an operator configures the endpoint and event subscriptions with you.
Use a public HTTPS endpoint on port 443. Its URL must not contain credentials, a query string or a fragment. Local or private addresses, mixed DNS answers and Mailcore domains are rejected. Mailcore validates DNS for each attempt, verifies TLS and does not follow redirects. A local development server is not directly reachable; use a separate accessible HTTPS environment.
| type | data.status |
|---|---|
| email.accepted | accepted |
| email.delayed | delayed |
| email.delivered | delivered |
| email.bounced | bounced |
| email.complained | complained |
| email.rejected | rejected |
| email.failed | failed |
| email.ambiguous | ambiguous |
Subscribe to the types you need. There are no email.queued or email.sending events. The payload contains the event id, type, createdAt and data with emailId, status and detail. It does not include the recipient or message content. One emailId can have several events; Webhook-Id identifies one particular event delivery record.
Webhook-Id: evt_0123456789abcdef01234567
Webhook-Timestamp: 1791964800
Webhook-Signature: v1,<hex HMAC-SHA256><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.
| Receiver | Requirements |
|---|---|
| Node.js | Node.js 22.13+; built-in node:sqlite, no additional packages |
| Python | Python 3.10+; standard library and SQLite |
| PHP | PHP 8.1+ and the SQLite3 extension; no Composer |
The local receivers bind to 127.0.0.1 at /mailcore-webhook. Set MAILCORE_WEBHOOK_SECRET and a persistent MAILCORE_WEBHOOK_INBOX path. Production requires a public HTTPS:443 reverse proxy and operator registration of the endpoint. Preserve raw bytes and reject duplicate signed headers at the proxy. Buffer/dechunk the body for Python/PHP; Python needs one valid Content-Length. PHP SAPI can hide duplicate headers, so rejecting them at the proxy is required.
{
"id": "evt_0123456789abcdef01234567",
"type": "email.delivered",
"createdAt": "2026-10-14T08:00:02.000Z",
"data": {
"emailId": "eml_0123456789abcdef01234567",
"status": "delivered",
"detail": "ses:delivery"
}
}// Node.js 22.13+; only built-in modules. Local receiver for integration testing.
// Put behind public HTTPS:443 in production; register it through the operator.
// Use a persistent inbox volume. The unique ID survives restarts; an application
// worker must apply business changes transactionally/idempotently from this
// inbox. Receipt here does NOT mean a business action has been performed.
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
const EVENT_STATUSES = new Set(['accepted', 'delayed', 'delivered', 'bounced', 'complained', 'rejected', 'failed', 'ambiguous']);
const MAX_BODY = 65_536;
export function verifyWebhook(rawBody, headers, secret, { now = Math.floor(Date.now() / 1000), toleranceSeconds = 300 } = {}) {
if (!Buffer.isBuffer(rawBody) || rawBody.length > MAX_BODY || typeof secret !== 'string' || !secret) {
throw new Error('Invalid webhook input');
}
const normalized = new Map();
for (const [name, value] of Object.entries(headers)) {
const key = name.toLowerCase();
if (!key.startsWith('webhook-')) continue;
if (normalized.has(key) || (Array.isArray(value) && value.length !== 1)) throw new Error('Duplicate header');
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') throw new Error('Invalid header');
normalized.set(key, text);
}
const id = normalized.get('webhook-id') ?? '';
const timestamp = normalized.get('webhook-timestamp') ?? '';
const signature = normalized.get('webhook-signature') ?? '';
if (!/^evt_[a-f0-9]{24}$/.test(id) || !/^[0-9]+$/.test(timestamp) || !/^v1,[a-f0-9]{64}$/.test(signature)) {
throw new Error('Invalid signed headers');
}
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || !Number.isFinite(now) || !Number.isFinite(toleranceSeconds) || toleranceSeconds < 0 || Math.abs(now - seconds) > toleranceSeconds) {
throw new Error('Timestamp outside tolerance');
}
// Sign the raw bytes BEFORE parsing JSON. The literal secret is UTF-8 text,
// not a base64-decoded key. Never stringify a parsed object for verification.
const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(Buffer.from(`${id}.${timestamp}.`, 'ascii')).update(rawBody).digest();
if (!timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'))) throw new Error('Invalid signature');
const event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
if (!event || typeof event !== 'object' || Array.isArray(event) || event.id !== id || typeof event.createdAt !== 'string' || Number.isNaN(Date.parse(event.createdAt))) {
throw new Error('Invalid event');
}
const data = event.data;
if (!data || typeof data !== 'object' || Array.isArray(data) || !/^eml_[a-f0-9]{24}$/.test(data.emailId ?? '') || !EVENT_STATUSES.has(data.status) || event.type !== `email.${data.status}` || !(data.detail === null || typeof data.detail === 'string')) {
throw new Error('Invalid event data');
}
return event;
}
export function startReceiver() {
const secret = process.env.MAILCORE_WEBHOOK_SECRET;
if (!secret) throw new Error('Set MAILCORE_WEBHOOK_SECRET; never log it');
const port = Number(process.env.PORT ?? 8080);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT');
const inbox = new DatabaseSync(process.env.MAILCORE_WEBHOOK_INBOX ?? 'mailcore-webhook-inbox.sqlite3');
inbox.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);');
const insert = inbox.prepare('INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (?, ?)');
const existing = inbox.prepare('SELECT raw_body FROM inbox WHERE id = ?');
const server = createServer((req, res) => {
const reply = (status, error) => {
if (res.writableEnded) return;
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(error ? { ok: false, error } : { ok: true }));
};
if (req.url !== '/mailcore-webhook') { req.resume(); reply(404, 'not_found'); return; }
if (req.method !== 'POST') { req.resume(); res.setHeader('Allow', 'POST'); reply(405, 'method_not_allowed'); return; }
if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') {
req.resume(); reply(415, 'content_type_required'); return;
}
const chunks = [];
let length = 0;
let rejected = false;
req.on('data', chunk => {
length += chunk.length;
if (length > MAX_BODY) { rejected = true; chunks.length = 0; reply(413, 'body_too_large'); return; }
if (!rejected) chunks.push(chunk);
});
req.on('error', () => reply(400, 'request_invalid'));
req.on('end', () => {
if (rejected) return;
const rawBody = Buffer.concat(chunks);
let event;
try { event = verifyWebhook(rawBody, req.headersDistinct, secret); }
catch { reply(401, 'webhook_invalid'); return; }
try {
// SQLite commits the unique inbox row before we acknowledge. Retain
// IDs for your retry/recovery policy and monitor unprocessed backlog.
insert.run(event.id, rawBody);
if (!Buffer.from(existing.get(event.id).raw_body).equals(rawBody)) {
reply(409, 'event_id_conflict'); return;
}
reply(200);
} catch { reply(500, 'inbox_unavailable'); }
});
});
server.requestTimeout = 10_000;
server.headersTimeout = 10_000;
server.listen(port, '127.0.0.1');
return server;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) startReceiver();
"""Python 3.10+, standard library. Local receiver; production needs HTTPS:443.
An operator configures the webhook. Persist the SQLite inbox on durable storage.
An application worker must apply business changes transactionally/idempotently
from this inbox. HTTP 200 acknowledges durable receipt, not a business action.
"""
import hashlib
import hmac
from contextlib import closing
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import math
import os
import re
import sqlite3
import time
MAX_BODY = 65_536
EVENT_STATUSES = {"accepted", "delayed", "delivered", "bounced", "complained", "rejected", "failed", "ambiguous"}
def verify_webhook(raw_body, headers, secret, *, now=None, tolerance_seconds=300):
if not isinstance(raw_body, bytes) or len(raw_body) > MAX_BODY or not isinstance(secret, str) or not secret:
raise ValueError("Invalid webhook input")
normalized = {}
for name, value in headers.items():
key = name.lower()
if not key.startswith("webhook-"):
continue
if key in normalized or (isinstance(value, (list, tuple)) and len(value) != 1):
raise ValueError("Duplicate header")
text = value[0] if isinstance(value, (list, tuple)) else value
if not isinstance(text, str):
raise ValueError("Invalid header")
normalized[key] = text
event_id = normalized.get("webhook-id", "")
timestamp = normalized.get("webhook-timestamp", "")
signature = normalized.get("webhook-signature", "")
if not re.fullmatch(r"evt_[a-f0-9]{24}", event_id) or not re.fullmatch(r"[0-9]{1,13}", timestamp) or not re.fullmatch(r"v1,[a-f0-9]{64}", signature):
raise ValueError("Invalid signed headers")
current = time.time() if now is None else now
if not math.isfinite(current) or not math.isfinite(tolerance_seconds) or tolerance_seconds < 0 or abs(current - int(timestamp)) > tolerance_seconds:
raise ValueError("Timestamp outside tolerance")
# Exact raw request bytes. The literal secret is UTF-8, not base64-decoded.
signed = (event_id + "." + timestamp + ".").encode("ascii") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature[3:]):
raise ValueError("Invalid signature")
event = json.loads(raw_body.decode("utf-8"))
if not isinstance(event, dict) or event.get("id") != event_id or not isinstance(event.get("createdAt"), str):
raise ValueError("Invalid event")
data = event.get("data")
if not isinstance(data, dict) or not isinstance(data.get("emailId"), str) or not re.fullmatch(r"eml_[a-f0-9]{24}", data["emailId"]):
raise ValueError("Invalid email ID")
status = data.get("status")
if not isinstance(status, str) or status not in EVENT_STATUSES or event.get("type") != "email." + status or "detail" not in data or not (data["detail"] is None or isinstance(data["detail"], str)):
raise ValueError("Invalid event data")
return event
def main():
secret = os.environ.get("MAILCORE_WEBHOOK_SECRET", "")
if not secret:
raise SystemExit("Set MAILCORE_WEBHOOK_SECRET; never log it")
inbox_path = os.environ.get("MAILCORE_WEBHOOK_INBOX", "mailcore-webhook-inbox.sqlite3")
with closing(sqlite3.connect(inbox_path)) as connection, connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA synchronous=FULL")
connection.execute("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, raw_body BLOB NOT NULL, received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)")
class Receiver(BaseHTTPRequestHandler):
# Quiet by default: never log bodies, tokens or webhook secrets.
def log_message(self, format, *args):
pass
def respond(self, status, error=None):
body = json.dumps({"ok": not bool(error), **({"error": error} if error else {})}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
if status == 405:
self.send_header("Allow", "POST")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
self.close_connection = True
def do_POST(self):
self.connection.settimeout(10)
if self.path != "/mailcore-webhook":
self.respond(404, "not_found")
return
if self.headers.get("Content-Type", "").split(";")[0].strip().lower() != "application/json":
self.respond(415, "content_type_required")
return
# This small demo does not parse chunked transfer coding. A reverse
# proxy must buffer/dechunk and supply one Content-Length header.
lengths = self.headers.get_all("Content-Length", [])
if self.headers.get("Transfer-Encoding") or len(lengths) != 1 or not re.fullmatch(r"[0-9]{1,10}", lengths[0]):
self.respond(400, "content_length_required")
return
size = int(lengths[0])
if size > MAX_BODY:
self.respond(413, "body_too_large")
return
try:
raw_body = self.rfile.read(size)
if len(raw_body) != size:
self.respond(400, "request_invalid")
return
signed_headers = {name: self.headers.get_all(name, []) for name in ("Webhook-Id", "Webhook-Timestamp", "Webhook-Signature")}
event = verify_webhook(raw_body, signed_headers, secret)
except (ValueError, UnicodeError, OSError, TypeError):
self.respond(401, "webhook_invalid")
return
try:
with closing(sqlite3.connect(inbox_path)) as connection, connection:
connection.execute("PRAGMA synchronous=FULL")
connection.execute("INSERT OR IGNORE INTO inbox (id, raw_body) VALUES (?, ?)", (event["id"], raw_body))
saved = connection.execute("SELECT raw_body FROM inbox WHERE id = ?", (event["id"],)).fetchone()[0]
if saved != raw_body:
self.respond(409, "event_id_conflict")
return
# Transaction has committed. A repeat now safely acknowledges
# the persisted ID; process the inbox separately and monitor it.
self.respond(200)
except sqlite3.Error:
self.respond(500, "inbox_unavailable")
def do_GET(self):
self.respond(405, "method_not_allowed")
do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_HEAD = do_TRACE = do_GET
port = int(os.environ.get("PORT", "8080"))
HTTPServer(("127.0.0.1", port), Receiver).serve_forever()
if __name__ == "__main__":
main()
<?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