Email API#
Send email from your cMailer domains with one HTTPS request. The mail server DKIM-signs and delivers it the same way as mail sent from a mailbox, and every send is logged in the portal.
- Base URL
- https://api.cmailer.net
- Format
- JSON request and response bodies, UTF-8
- Authentication
Authorization: Bearer cm_…- Endpoints
GET /v1lists them and needs no key.
Quick start#
- Sign in to the portal and create a key under Access → API keys with sending access for your domain. The key is shown once, so store it somewhere safe; the examples read it from
CMAILER_API_KEY. - Send a message with
POST /v1/emails. Thefromaddress must be one the key can send from (see Sender addresses). - The response has an
id. FetchGET /v1/emails/{id}to see what happened to the message, or find it in the portal under Mail → Outbound.
curl https://api.cmailer.net/v1/emails \
-H "Authorization: Bearer $CMAILER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.example>",
"to": ["jane@example.com"],
"subject": "Your Acme account is ready",
"html": "<p>Hi Jane, welcome aboard.</p>"
}'{
"id": "3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31",
"status": "queued",
"queue_id": "4cKq2mT7Xz",
"message_id": "<3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31@acme.example>",
"created_at": "2026-09-23T09:14:03.000Z"
}Authentication#
Send the key as a bearer token on every request. Keys start with cm_, are shown once when they’re created and are stored only as a hash, so a lost key can’t be recovered: create a new one and revoke the old one. A revoked or disabled key fails straight away with 401 invalid_api_key.
A key only covers domains its owner can still manage. If the owner loses access to a domain, or the domain is removed from cMailer, the key loses it too, and a key left with no domains stops working.
Key types#
| Key | Can send as | Email API | Management API |
|---|---|---|---|
| Domain key, sending only | Any address at its domains that the mail server knows (Sender addresses) | Yes | No |
| Domain key, full access | The same as sending only | Yes | Yes |
| Domain key, management only | Nothing | No | Yes |
| Inbox key | Only the exact addresses chosen for it, up to 50 | Yes | No |
Older key starting ext_ | Nothing | No | Lists domains and forwards; turns a forward on or off |
A send-only domain can only be given to a key with sending access: this server holds no mailboxes or forwards for it to manage.
Sender addresses#
The mail server only accepts senders it knows, so the from address must already exist:
- On a hosted domain, an active mailbox or forwarding address. If the domain has an active catch-all, any address at it works.
- On a send-only domain, an address on the domain’s approved senders list, and the domain must be verified.
- With an inbox key, only the addresses chosen for the key.
Anything else returns 403 sender_not_allowed and nothing is sent, as does sending from a suspended domain. Add addresses and approved senders in the portal. GET /v1/domains shows which domains and addresses a key covers.
Authorization: Bearer cm_0f3a…{
"statusCode": 401,
"name": "missing_api_key",
"message": "Missing API key. Use: Authorization: Bearer cm_xxx"
}{
"statusCode": 403,
"name": "sender_not_allowed",
"message": "billing@acme.example does not exist on acme.example. Create it as a mailbox or forwarding address in the portal first, or send from an existing address."
}Send an email#
POST/v1/emails
Builds the message, hands it to the mail server and returns once the server has queued it. Delivery to each recipient happens afterwards; retrieve the email to see the outcome.
Body#
A JSON object with these fields. Unknown fields are rejected, so a typo can’t silently drop content.
| Field | Description |
|---|---|
fromrequiredstring | object | "Name <user@domain>", a bare address or { "name", "email" }. Must be an address this key can send from (Sender addresses). |
torequiredstring | array | One or more recipients, each "Name <user@example.com>", a bare address or { "name", "email" }. One string may hold several comma-separated addresses. Repeated addresses are sent once. |
ccoptionalstring | array | Copy recipients, written the same way as to. |
bccoptionalstring | array | Recipients who get the message but never appear in its headers. |
reply_tooptionalstring | array | Where replies go, when that differs from from. replyTo is accepted too. |
subjectrequiredstring | Up to 998 characters. Line breaks and control characters are rejected; runs of whitespace become one space. |
htmlhtml or textstring | HTML body. Send html, text or both; with both, mail clients show the part they can display. |
texthtml or textstring | Plain-text body. |
headersoptionalobject | Up to 20 custom headers, such as { "X-Entity-Ref-ID": "order-2048" }. Names are up to 80 letters, digits and hyphens; values are up to 1,000 characters. Headers the mail system sets are rejected: From, To, Cc, Bcc, Subject, Date, Message-ID, Reply-To, Sender, Return-Path, Content-Type, Content-Transfer-Encoding, MIME-Version, DKIM-Signature, Received, Authentication-Results, Received-SPF, X-Original-To, Delivered-To. |
attachmentsoptionalarray | Up to 20 files. See Attachments. |
tagsoptionalarray | Up to 10 { "name", "value" } pairs for your own records, each 1 to 256 letters, digits, _ or -. Stored in the send log and returned with the email; never added to the message. |
Response#
A 200 means the mail server accepted the message into its queue.
| Field | Meaning |
|---|---|
id | The email’s ID in this API, for GET /v1/emails/{id}. |
status | queued: the mail server accepted the message. A refusal is an error instead (below). pending only appears when an idempotent replay catches the first request still being handed over. |
queue_id | The Postfix queue ID from the server’s reply, for matching server logs or the portal’s message journey. null if the reply didn’t include one. |
message_id | The Message-ID header written into the email: <{id}@{sender’s domain}>. |
created_at | When the request was accepted, in ISO 8601 UTC. |
Errors#
| Error | When |
|---|---|
400 validation_error | A field is missing, malformed or unknown, or Idempotency-Key is invalid. The message names the field. |
403 sender_not_allowed | The from address can’t be used by this key (Sender addresses). |
409 conflict | An Idempotency-Key clash (Idempotency). |
413 message_too_large | The encoded message is over 10 MB. |
502 smtp_error | The mail server refused the message; message includes its reply. The body has the id of the logged attempt. |
503 mail_server_unavailable | The mail server couldn’t be reached, so nothing was sent. Retry shortly, with the same Idempotency-Key if you used one. |
Idempotency#
Retrying after a network failure can send a message twice. Add an Idempotency-Key header (1 to 255 letters, digits, ., :, _ or -) and the API remembers the result for that key:
- If the first request was accepted, or is still being handed over, a repeat returns the original result with an
Idempotent-Replayed: trueheader and sends nothing. The repeat’s body is still validated, but not compared with the first. - If the first request failed with
502or503, a repeat with the same sender, subject and recipients sends it again, keeping the sameidandMessage-ID. A repeat with a different email returns409 conflict, as does a repeat sent while that retry is still running. - Keys belong to the API key that used them and last as long as the send log: 90 days.
- Batches ignore
Idempotency-Key.
curl https://api.cmailer.net/v1/emails \
-H "Authorization: Bearer $CMAILER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2048-confirmation" \
-d '{
"from": "Acme Orders <orders@acme.example>",
"to": ["Jane Doe <jane@example.com>"],
"cc": ["accounts@acme.example"],
"reply_to": "support@acme.example",
"subject": "Order #2048 confirmed",
"html": "<h1>Thanks, Jane</h1><p>Your order ships tomorrow.</p>",
"text": "Thanks, Jane. Your order ships tomorrow.",
"headers": { "X-Entity-Ref-ID": "order-2048" },
"tags": [{ "name": "category", "value": "order_confirmation" }]
}'{
"id": "3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31",
"status": "queued",
"queue_id": "4cKq2mT7Xz",
"message_id": "<3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31@acme.example>",
"created_at": "2026-09-23T09:14:03.000Z"
}Attachments and inline images#
Attach files to POST /v1/emails, or to any email in a batch, as base64 in the attachments array.
| Field | Description |
|---|---|
filenamerequiredstring | The name the recipient sees. Only the base name is kept (any path is dropped), up to 255 characters. |
contentrequiredstring | The file’s bytes, base64-encoded. Whitespace is ignored. |
content_typeoptionalstring | MIME type, such as application/pdf. Worked out from filename when left out. contentType is accepted too. |
content_idoptionalstring | Makes the file an inline image the HTML can show as cid:…. Must look like logo@acme.example. contentId is accepted too. |
Base64 makes files about a third larger, and the size limit applies to the encoded message, so keep the files themselves well under 10 MB. Remote URLs and file paths (url, href, path) are rejected: send the bytes.
Inline images#
Give the attachment a content_id and use it in the HTML as <img src="cid:logo@acme.example">. Attachments with a content_id are sent inline; the others are ordinary attachments.
curl https://api.cmailer.net/v1/emails \
-H "Authorization: Bearer $CMAILER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme Orders <orders@acme.example>",
"to": ["jane@example.com"],
"subject": "Order 2048 summary",
"text": "Your order summary is attached.",
"attachments": [
{
"filename": "order-2048.txt",
"content": "T3JkZXIgMjA0OAoyIHNvdXJkb3VnaCBsb2F2ZXMsIDEgbGVtb24gY2FrZQpTaGlwcyB0b21vcnJvdy4K",
"content_type": "text/plain"
}
]
}'curl https://api.cmailer.net/v1/emails \
-H "Authorization: Bearer $CMAILER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.example>",
"to": ["jane@example.com"],
"subject": "Welcome to Acme",
"html": "<p>Welcome aboard.</p><img src=\"cid:logo@acme.example\" alt=\"Acme\">",
"attachments": [
{
"filename": "logo.png",
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
"content_type": "image/png",
"content_id": "logo@acme.example"
}
]
}'Send a batch#
POST/v1/emails/batch
Sends up to 100 separate emails in one request.
The body is a JSON array of the objects POST /v1/emails accepts. The whole batch is checked first, senders included: if any email is invalid, the request fails with its position (counting from 0) in the message, such as [3] subject: is required, and nothing is sent.
Then each email is handed to the mail server in turn and reported on its own, so one refusal doesn’t undo the others. The batch is charged against the rate limit up front, one per email; if there isn’t room for all of it, the whole batch gets 429 and nothing is sent. Idempotency-Key doesn’t apply to batches.
Response#
data lists one result per email, in the order they were sent.
| Field | Meaning |
|---|---|
id | The email’s ID, or null if it failed before it was logged. |
status | queued or failed. |
error | On failures only: { "name", "message" }, as in Errors. |
curl https://api.cmailer.net/v1/emails/batch \
-H "Authorization: Bearer $CMAILER_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{
"from": "Acme <hello@acme.example>",
"to": ["sam@example.com"],
"subject": "Your sign-in code",
"text": "Your code is 482913."
},
{
"from": "Acme <hello@acme.example>",
"to": ["lee@example.com"],
"subject": "Your sign-in code",
"text": "Your code is 730155."
}
]'{
"data": [
{ "id": "9c0d4e2a-51b7-4c38-8f7e-2d64a1b3c905", "status": "queued" },
{
"id": "e41f0b6c-2a9d-4c57-b8e3-7f1d0a5c6b29",
"status": "failed",
"error": {
"name": "smtp_error",
"message": "The mail server did not accept the message: 452 4.3.1 Insufficient system storage"
}
}
]
}Retrieve an email#
GET/v1/emails/{id}
Returns an email’s send-log record and, where the server’s delivery log can be read, what happened to it after it was queued.
Bodies and attachments are never stored, so they can’t be returned. Domain keys can read any email sent from their domains, whichever key sent it; inbox keys only see emails they sent. Anything else is 404 not_found.
Response#
| Field | Meaning |
|---|---|
object | Always email. |
id | The email’s ID. |
from, to, cc, bcc, reply_to | As sent, each address written as Name <user@domain> or a bare address. from is a string; the others are arrays. |
subject | As sent. |
status | The send-log state: queued (accepted by the mail server), failed (refused, or the server couldn’t be reached) or pending (being handed over). |
queue_id, message_id | As in the send response. |
error | Why a failed send didn’t go, or null. |
size_bytes | Size of the encoded message. |
attachments | How many files were attached. The files themselves aren’t stored. |
tags, idempotency_key | As sent, or [] and null. |
created_at | When the request was accepted, in ISO 8601 UTC. |
delivery | What happened after the mail server queued it: see Delivery status. Only on this endpoint. |
Delivery status#
delivery has a status, a detail (the receiving server’s reply or an explanation), checked_at and a source: postfix-log, api (the send log itself) or none. When the log was read for this request it also has events, the number of log entries found.
| delivery.status | Meaning |
|---|---|
delivered | Every recipient’s mail server accepted the message. |
partially_delivered | Delivered to some recipients; the others have no outcome in the log yet. |
deferred | A recipient’s mail server asked cMailer to try again later, and none refused. Postfix keeps retrying on its own. |
bounced | At least one recipient’s mail server refused the message, even if others accepted it. detail has its reply. |
queued | Accepted by the mail server; no delivery attempt logged yet. |
unknown | No outcome to report: the email isn’t in the retained log, is more than 7 days old, or hasn’t reached the mail server yet. This isn’t proof it was lost. |
unavailable | This server doesn’t let the API read its Postfix log, or the log couldn’t be read. Only the acceptance in status is known. |
not_sent | The mail server refused the message or couldn’t be reached when it was sent (status is failed). |
Outcomes are read from the log when you ask and reused for a minute, so polling more often returns the same answer. delivered and bounced are final and never re-read. Emails older than 7 days aren’t looked up.
curl https://api.cmailer.net/v1/emails/3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31 \
-H "Authorization: Bearer $CMAILER_API_KEY"{
"object": "email",
"id": "3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31",
"from": "Acme Orders <orders@acme.example>",
"to": ["Jane Doe <jane@example.com>"],
"cc": ["accounts@acme.example"],
"bcc": [],
"reply_to": ["support@acme.example"],
"subject": "Order #2048 confirmed",
"status": "queued",
"queue_id": "4cKq2mT7Xz",
"message_id": "<3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31@acme.example>",
"error": null,
"size_bytes": 4821,
"attachments": 0,
"tags": [{ "name": "category", "value": "order_confirmation" }],
"idempotency_key": "order-2048-confirmation",
"created_at": "2026-09-23T09:14:03.000Z",
"delivery": {
"status": "delivered",
"detail": "250 2.0.0 OK 1790154911 x12si9876543 - gsmtp",
"checked_at": "2026-09-23T09:15:11.000Z",
"source": "postfix-log",
"events": 4
}
}List emails#
GET/v1/emails
Lists emails newest first, a page at a time.
Domain keys see every email sent from their domains; inbox keys see their own. Entries have the same fields as Retrieve an email without delivery: fetch an email by id for its delivery outcome.
Query parameters#
| Parameter | Description |
|---|---|
limitoptionalinteger | Emails per page, 1 to 200. Defaults to 50; values outside the range are brought within it. |
statusoptionalstring | Only emails in this send-log state: queued, failed or pending. |
cursoroptionalstring | The next_cursor from the previous page. |
Response#
| Field | Meaning |
|---|---|
object | Always list. |
data | Emails, newest first. |
next_cursor | Pass as cursor for the next page, or null on the last page. |
has_more | Whether there’s another page. |
curl "https://api.cmailer.net/v1/emails?limit=25&status=queued" \
-H "Authorization: Bearer $CMAILER_API_KEY"{
"object": "list",
"data": [
{
"object": "email",
"id": "3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31",
"from": "Acme Orders <orders@acme.example>",
"to": ["Jane Doe <jane@example.com>"],
"cc": ["accounts@acme.example"],
"bcc": [],
"reply_to": ["support@acme.example"],
"subject": "Order #2048 confirmed",
"status": "queued",
"queue_id": "4cKq2mT7Xz",
"message_id": "<3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31@acme.example>",
"error": null,
"size_bytes": 4821,
"attachments": 0,
"tags": [{ "name": "category", "value": "order_confirmation" }],
"idempotency_key": "order-2048-confirmation",
"created_at": "2026-09-23T09:14:03.000Z"
}
],
"next_cursor": "MTc5MDE1NDg0MzozZjJiMWM3ZS02ZDRhLTRmMGItOWMxZS04YTdkMmI1ZTRjMzE",
"has_more": true
}Domains#
GET/v1/domains
Shows what the calling key can send: its scope, its permissions and the domains or addresses it can send from.
Domains and addresses are added and verified in the portal, not through this API.
Response#
| Field | Meaning |
|---|---|
key.name | The key’s name in the portal. |
key.scope | domain for a domain key, mailbox for an inbox key. |
key.permissions | send, manage or both. |
data[].name | A domain the key can send from. |
data[].senders | For a domain key, *@domain: any address there that the mail server knows (Sender addresses). For an inbox key, its exact addresses. |
curl https://api.cmailer.net/v1/domains \
-H "Authorization: Bearer $CMAILER_API_KEY"{
"object": "list",
"key": { "name": "Order emails", "scope": "domain", "permissions": ["send"] },
"data": [
{
"object": "domain",
"name": "acme.example",
"senders": ["*@acme.example"]
}
]
}{
"object": "list",
"key": {
"name": "Support desk",
"scope": "mailbox",
"permissions": ["send"]
},
"data": [
{
"object": "domain",
"name": "acme.example",
"senders": ["support@acme.example"]
}
]
}Errors#
Errors use the HTTP status code and a JSON body with statusCode, name (stable, for your code to check) and message (for a developer; it names the field at fault where there is one). A send that the mail server refused or couldn’t take also includes the id of the logged attempt.
| Status | name | When |
|---|---|---|
| 400 | validation_error | A field is missing, malformed or unknown, the body isn’t valid JSON, or a header or query value is invalid. Nothing was sent. |
| 401 | missing_api_key | No Authorization: Bearer header. |
| 401 | invalid_api_key | The key is malformed, unknown, revoked or disabled, or its owner no longer exists. |
| 403 | forbidden | The key doesn’t have the access this API needs (Key types), or has no domains left. |
| 403 | sender_not_allowed | This key can’t send from the from address (Sender addresses). |
| 404 | not_found | No email with that ID is visible to this key, or there’s no such route. |
| 409 | conflict | An Idempotency-Key clash (Idempotency). |
| 413 | message_too_large | The encoded message is over 10 MB. |
| 429 | rate_limit_exceeded | The key is over its rate limit. Wait for Retry-After seconds. |
| 500 | internal_server_error | Something failed on cMailer’s side. Retry, and contact support if it keeps happening. |
| 502 | smtp_error | The mail server refused the message; message includes its reply. Logged as failed. |
| 503 | mail_server_unavailable | The mail server couldn’t be reached. Nothing was sent; retry shortly. |
{
"statusCode": 400,
"name": "validation_error",
"message": "to[0]: \"jane@example\" is not a valid email address"
}{
"statusCode": 502,
"name": "smtp_error",
"message": "The mail server did not accept the message: 452 4.3.1 Insufficient system storage",
"id": "3f2b1c7e-6d4a-4f0b-9c1e-8a7d2b5e4c31"
}Limits#
This server’s current limits, read from its configuration. They’re also published as JSON at GET /docs/limits, which needs no key.
| Limit | Value |
|---|---|
Recipients per email, across to, cc and bcc | 50 |
| Attachments per email | 20 |
| Encoded message size, attachments included | 10 MB (10,485,760 bytes) |
| Emails per batch | 100 |
| Requests per key per minute | 120 |
| Custom headers per email | 20 |
| Tags per email | 10 |
| Subject length | 998 characters |
| Emails per list page | 200 (default 50) |
| Send log and idempotency keys kept for | 90 days |
| Delivery lookups cover | the last 7 days |
curl https://api.cmailer.net/docs/limits{
"base_url": "https://api.cmailer.net",
"portal_url": "https://portal.cmailer.net",
"max_recipients": 50,
"max_attachments": 20,
"max_message_bytes": 10485760,
"max_batch": 100,
"max_tags": 10,
"max_custom_headers": 20,
"max_subject_length": 998,
"rate_limit_per_minute": 120,
"retention_days": 90
}Rate limits#
Each key can make 120 requests a minute to /v1, counted over a sliding 60-second window. Reads count too, a batch costs one per email, and a send that fails validation still counts. The management API isn’t part of this limit.
Every request that counts gets these headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | When the oldest counted request leaves the window, in Unix seconds. |
Retry-After | On 429 only: seconds to wait before retrying. |
When the limit is reached the response is 429 rate_limit_exceeded and nothing is sent. Wait for the Retry-After seconds before trying again.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790154855{
"statusCode": 429,
"name": "rate_limit_exceeded",
"message": "Rate limit of 120 messages per minute exceeded. Retry after 12s."
}Management API#
Domain keys with full access or management-only access can also manage mailboxes and forwards on their domains, with the same bearer header and base URL. Inbox keys and sending-only keys get 403.
These endpoints predate /v1 and answer in the portal’s own format: successful responses vary by endpoint (below), and errors carry statusCode and statusMessage (the reason in words) rather than the email API’s name codes.
| Endpoint | What it does |
|---|---|
GET /api/external/domains | Lists the domains the key covers. Returns { data }. |
GET /api/external/mailboxes?domain= | Lists mailboxes with their name, quota and state; domain is optional. Returns { data }. |
POST /api/external/mailboxes | Creates a mailbox from { email, password, name?, quota? }. Returns { success, data }. |
POST /api/external/mailboxes/reset-password | Sets a new password from { email, password }. Returns { success, message }. |
GET /api/external/forwardings?domain= | Lists forwards and catch-alls; domain is optional. Returns { domains, count, data }. |
POST /api/external/forwardings | Creates a forward from { domain, address, forwarding, active? }. Use @domain as the address for a catch-all. Returns { success, data }. |
PUT /api/external/forwardings/{id} | Changes a forward’s destination or turns it on or off, from { forwarding, active? }. Send forwarding every time. Returns { success, data }. |
DELETE /api/external/forwardings/{id} | Removes a forward. Returns { success, message }. |
Mailbox passwords must be 8 to 128 characters and include an uppercase letter, a lowercase letter, a digit and a special character. quota is in MB. Left out, it’s the server’s default, capped at the domain’s own limit; a quota over that limit is rejected, and so is 0 (no limit) on a domain that has one.
Older keys starting ext_ can only list domains and forwards and turn a forward on or off.
curl https://api.cmailer.net/api/external/mailboxes \
-H "Authorization: Bearer $CMAILER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@acme.example",
"password": "Replace-with-a-strong-passw0rd",
"name": "Jane Doe",
"quota": 2048
}'{
"success": true,
"data": {
"username": "jane@acme.example",
"domain": "acme.example",
"name": "Jane Doe",
"quota": 2048
}
}Good to know#
- Signing and alignment. Mail is DKIM-signed on the way out. A hosted domain with its own DKIM key is signed as itself; other domains, including every send-only domain, are signed as cmailer.net, so their DMARC passes on SPF alignment. The records cMailer set up for a domain already cover mail sent through the API.
- Bounces go to the
fromaddress, as with mail sent from a mailbox, so send from a real address someone reads. - No scheduling or templates. Send each message when it’s due and render its content in your application.
scheduled_atis rejected. - No webhooks. Poll
GET /v1/emails/{id}for outcomes; answers are reused for a minute, so there’s no point polling faster. - Privacy. The send log keeps addresses, subject, size, attachment count, tags and outcome for 90 days. Message bodies and attachments are never stored.