API reference

Mail Service API

A small HTTP API for sending transactional email. You send a JSON request, the service queues it and returns 202 Accepted straight away, and background workers deliver the mail over SMTP.

Quick start

  1. Get an API key. Request one from the home page. Keys look like msk_ followed by 64 hex characters.
  2. Store it as a secret. Put it in an environment variable on your server, never in client-side code.
  3. Send a request with the key in the X-API-Key header:
export API_KEY="msk_your_key_here"

curl -X POST {{BASE}}/send \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{"to": "ada@example.com", "subject": "Hello", "body": "It works!"}'

A 202 Accepted response with the body mail queued means the mail is in the delivery queue.

Base URL & format

All endpoints are relative to this server:

{{BASE}}
  • Send request bodies as JSON with Content-Type: application/json.
  • Request bodies are limited to 1 MB.
  • Successful send calls answer with plain text such as mail queued.
  • Authentication and rate-limit errors answer with JSON containing an error field. Validation errors are plain text. See Errors.
  • Use HTTPS in production so your API key is never sent in clear text.

Authentication

Every endpoint except /health and /api/status needs a valid API key. Send it in either header; X-API-Key is checked first.

POST /send HTTP/1.1
Content-Type: application/json
X-API-Key: msk_3f9c…

How keys are checked

  1. The key must exist. Unknown keys get 401 invalid API key.
  2. The key must be active. Keys an administrator has disabled get 403 API key is disabled.
  3. Your IP must be allowed by the key (see IP restrictions), otherwise 403.
  4. The request must fit within the key's rate limits, otherwise 429.
Keep keys secret. Anyone with your key can send mail as you. Never ship it in browser or mobile apps, never commit it to source control, and ask an administrator to disable it immediately if it leaks.
Super keys are a special kind of key for trusted internal systems. They have no rate limits and work from any IP. Their requests are still logged.

IP restrictions

Each key has an allow list of client addresses. It can be:

EntryMeaning
*Any IP address (the default).
203.0.113.5Exactly this IPv4 or IPv6 address.
10.0.0.0/8Any address in this CIDR range.

Requests from other addresses are rejected before any limit is checked:

{
  "error": "IP address 198.51.100.7 is not allowed for this API key"
}

Ask for IP restrictions when you request your key. They make a leaked key useless from anywhere else.

Rate limits

Each key can have up to four limits. They count mails, not HTTP requests: a bulk request to 20 recipients uses 20.

WindowLengthError label
HourRolling 60 minutesHourly limit exceeded
DayRolling 24 hoursDaily limit exceeded
WeekRolling 7 daysWeekly limit exceeded
MonthRolling 30 daysMonthly limit exceeded
  • Windows are rolling: usage from 61 minutes ago no longer counts towards the hourly limit.
  • A request that would exceed any limit is rejected as a whole. Nothing from it is queued or counted.
  • Rejected and invalid requests do not use up your limits.
  • Limits are enforced atomically, so parallel requests cannot go over a limit.

When a limit is reached

You get 429 Too Many Requests with a Retry-After header in seconds and a JSON body describing the limit:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1834
{
  "error": "Hourly limit exceeded: 98 of 100 mails already used in the last hour and this request needs 5 more. Try again after 2026-09-18T17:23:08Z",
  "window": "hour",
  "limit": 100,
  "used": 98,
  "requested": 5,
  "retry_at": "2026-09-18T17:23:08Z"
}
FieldTypeDescription
errorstringHuman-readable explanation.
windowstringhour, day, week or month.
limitintegerMaximum mails allowed in the window.
usedintegerMails already sent in the window.
requestedintegerMails in the rejected request.
retry_atstring | nullEarliest time (RFC 3339, UTC) the request will fit, or null if it never will.
If retry_at is null, the request alone is bigger than the limit, for example 150 recipients against an hourly limit of 100. Waiting won't help; split the request into smaller batches.
POST

/send

Queue one email to a single recipient. Uses 1 mail from your limits.

Request body

FieldTypeRequiredDescription
tostringYes*Recipient email address.
targetstringYes*Alias for to, used when to is empty.
subjectstringNoSubject line.
bodystringYesPlain-text message body.

* Provide to or target.

{
  "to": "ada@example.com",
  "subject": "Your order has shipped",
  "body": "Hi Ada, your order #1042 is on its way."
}

Responses

StatusBodyMeaning
202mail queuedAccepted for delivery.
400invalid JSONThe body is not valid JSON, or is larger than 1 MB.
400recipient and body are requiredMissing to/target or body.
405(empty)Method other than POST.

Plus the authentication and limit errors common to all endpoints.

POST

/send/bulk

Queue the same email to many recipients. Each recipient is a separate email and uses 1 mail from your limits. Blank entries are ignored and not counted.

Request body

FieldTypeRequiredDescription
recipientsstring[]YesRecipient addresses. At least one non-blank entry.
subjectstringNoSubject line, shared by all mails.
bodystringYesPlain-text body, shared by all mails.
{
  "recipients": ["ada@example.com", "grace@example.com", "alan@example.com"],
  "subject": "Scheduled maintenance",
  "body": "We will be offline on Sunday from 02:00 to 03:00 UTC."
}

Responses

StatusBodyMeaning
2023 mails queuedNumber of mails accepted for delivery.
400recipients and body are requiredNo recipients, or empty body.
POST

/send/template

Like /send/bulk, but {{placeholders}} in the body are replaced with values from meta before sending.

Request body

FieldTypeRequiredDescription
recipientsstring[]YesRecipient addresses.
subjectstringNoSubject line. Placeholders are not replaced here.
bodystringYesBody containing placeholders such as {{name}}.
metaobjectNoMap of placeholder name to an array of strings.
{
  "recipients": ["ada@example.com", "grace@example.com"],
  "subject": "Welcome to Acme",
  "body": "Hello {{team}} member, welcome to {{company}}!",
  "meta": {
    "team": ["Platform"],
    "company": ["Acme Inc."]
  }
}

How placeholders are filled

  • Every {{key}} is replaced with the first value of meta.key.
  • The same rendered body goes to every recipient. Values are not matched to recipients by position. To personalise per person, call /send once per recipient.
  • A key with an empty array is replaced with an empty string.
  • Placeholders without a matching key are left unchanged.

Responses

StatusBodyMeaning
2022 templated mails queuedNumber of mails accepted for delivery.
400recipients and body are requiredNo recipients, or empty body.
GET

/health

No auth

Liveness probe for load balancers and container orchestrators. Returns 200 OK with the body ok while the process is running.

curl {{BASE}}/health
GET

/api/status

No auth

Service status as JSON. Always returns 200; check the status field.

{
  "status": "operational",
  "version": "1.1.0",
  "uptime_seconds": 86400,
  "database": "ok",
  "delivery": "smtp",
  "workers": 3,
  "server_time": "2026-09-18T16:40:00Z"
}
FieldValues
statusoperational or degraded (database unreachable).
databaseok or unavailable.
deliveryThe default server: smtp when one is configured, simulation when mails without a key-specific server are only logged.
workersNumber of delivery workers.

Errors

These can be returned by any authenticated endpoint. JSON errors always have an error string.

StatusErrorWhat to do
400Validation message (plain text)Fix the request body. invalid JSON is also returned for bodies over 1 MB.
401missing API key: …Send the X-API-Key header.
401invalid API keyCheck for typos or whitespace; the key may have been deleted.
403API key is disabledContact an administrator.
403IP address … is not allowed for this API keyCall from an allowed IP, or ask for your IP to be added.
429… limit exceeded: …Wait for Retry-After, or split the request if retry_at is null.
500Queue error (plain text)Retry later.
500the SMTP settings for this API key could not be loaded; …Contact an administrator. Nothing was counted against your limits.
503authentication temporarily unavailable or could not verify usage limits, try againThe database is unreachable. Retry with backoff.
{
  "error": "invalid API key"
}

Delivery behaviour

  • 202 means queued, not delivered. Mails are delivered by background workers shortly afterwards.
  • Each API key can have its own SMTP server. Mail sent with your key goes out through the server and From address an administrator assigned to it. Keys without one use the service's default server. See Your own SMTP server.
  • The queue lives in memory. Mails still in the queue are lost if the service restarts, so resend anything critical if you see an outage on the status panel.
  • Bodies are sent as plain UTF-8 text. HTML is not rendered. Line breaks in subject are replaced with spaces.
  • Delivery failures (for example, a rejected address) are recorded on the server. They are not reported back to the caller. An administrator can see them and send failed mail again.
  • Every mail is recorded. The service keeps each mail's sender, recipient, subject, body and delivery status so administrators can trace and troubleshoot delivery.
  • When delivery is simulation, mails are only logged. Use this for testing.

Your own SMTP server

By default, mail goes out through the service's own SMTP server. You can ask for your key to use your own mail host instead, so mail is sent from your domain and address. The API calls stay exactly the same.

How to request it

  1. In the request form, tick Send through my own SMTP server and fill in the details below.
  2. An administrator reviews the request. If it is approved, they contact you by email to get the SMTP password privately.
  3. Once the server is set on your key, every mail sent with that key goes through it.

Already have a key? Send a new request with the same details and mention your existing key in the message.

Details we need

SettingRequiredDescription
SMTP hostYesYour mail server, for example smtp.gmail.com or smtp.office365.com.
PortNo587 if left empty. Port 465 uses implicit TLS. Other ports use STARTTLS when the server offers it.
UsernameNoThe account used to sign in to the SMTP server. Leave empty if your server does not need a login.
PasswordIf a username is setNever put it in the form. We ask for it privately after approval, and it is stored encrypted.
From addressYesThe address your mail is sent from, for example no-reply@yourdomain.com.
Before you ask: make sure your SMTP account is allowed to send from the From address, and that your domain's SPF and DKIM records include your mail server. Otherwise mail may be rejected or land in spam. Many providers, such as Gmail, need an app password instead of your normal password.

Code examples

Sending one email and handling rate limits correctly.

curl -i -X POST {{BASE}}/send \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{
    "to": "ada@example.com",
    "subject": "Password reset",
    "body": "Use this link to reset your password: https://example.com/reset/abc123"
  }'

Bulk send in batches

Keep each bulk request within your hourly limit and back off when you hit it:

async function sendBulk(recipients, subject, body, batchSize = 50) {
  for (let i = 0; i < recipients.length; i += batchSize) {
    const batch = recipients.slice(i, i + batchSize);
    for (;;) {
      const res = await fetch("{{BASE}}/send/bulk", {
        method: "POST",
        headers: { "Content-Type": "application/json", "X-API-Key": process.env.MAIL_API_KEY },
        body: JSON.stringify({ recipients: batch, subject, body }),
      });
      if (res.status === 202) break;
      if (res.status !== 429) throw new Error(`Mail API ${res.status}: ${await res.text()}`);

      const err = await res.json();
      if (err.retry_at === null) throw new Error("Batch larger than limit: " + err.error);
      const wait = Number(res.headers.get("Retry-After") || 60);
      await new Promise((r) => setTimeout(r, wait * 1000));
    }
  }
}

Best practices

Call from your server

Never expose your key in browsers or mobile apps. Proxy requests through your backend.

Store keys as secrets

Use environment variables or a secret manager, and keep keys out of logs and source control.

Respect Retry-After

On 429, wait the number of seconds given before retrying. Don't retry in a tight loop.

Batch sensibly

Keep bulk requests within your hourly limit. A request larger than a limit can never succeed.

Lock keys to your IPs

Ask for an IP allow list so a leaked key cannot be used from anywhere else.

One key per system

Separate keys make usage easy to track and let you revoke one integration without touching others.

Postman collection

The repository includes postman_collection.json with requests for every endpoint. Import it, open the collection's Variables tab and set apiKey to your key. Every mail request then sends it as X-API-Key.