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
- Get an API key. Request one from the home page. Keys look like
msk_followed by 64 hex characters. - Store it as a secret. Put it in an environment variable on your server, never in client-side code.
- Send a request with the key in the
X-API-Keyheader:
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
errorfield. 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…
POST /send HTTP/1.1 Content-Type: application/json Authorization: Bearer msk_3f9c…
How keys are checked
- The key must exist. Unknown keys get
401 invalid API key. - The key must be active. Keys an administrator has disabled get
403 API key is disabled. - Your IP must be allowed by the key (see IP restrictions), otherwise
403. - The request must fit within the key's rate limits, otherwise
429.
IP restrictions
Each key has an allow list of client addresses. It can be:
| Entry | Meaning |
|---|---|
* | Any IP address (the default). |
203.0.113.5 | Exactly this IPv4 or IPv6 address. |
10.0.0.0/8 | Any 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.
| Window | Length | Error label |
|---|---|---|
| Hour | Rolling 60 minutes | Hourly limit exceeded |
| Day | Rolling 24 hours | Daily limit exceeded |
| Week | Rolling 7 days | Weekly limit exceeded |
| Month | Rolling 30 days | Monthly 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"
}
| Field | Type | Description |
|---|---|---|
error | string | Human-readable explanation. |
window | string | hour, day, week or month. |
limit | integer | Maximum mails allowed in the window. |
used | integer | Mails already sent in the window. |
requested | integer | Mails in the rejected request. |
retry_at | string | null | Earliest time (RFC 3339, UTC) the request will fit, or null if it never will. |
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.
/send
Queue one email to a single recipient. Uses 1 mail from your limits.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes* | Recipient email address. |
target | string | Yes* | Alias for to, used when to is empty. |
subject | string | No | Subject line. |
body | string | Yes | Plain-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
| Status | Body | Meaning |
|---|---|---|
| 202 | mail queued | Accepted for delivery. |
| 400 | invalid JSON | The body is not valid JSON, or is larger than 1 MB. |
| 400 | recipient and body are required | Missing to/target or body. |
| 405 | (empty) | Method other than POST. |
Plus the authentication and limit errors common to all endpoints.
/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
| Field | Type | Required | Description |
|---|---|---|---|
recipients | string[] | Yes | Recipient addresses. At least one non-blank entry. |
subject | string | No | Subject line, shared by all mails. |
body | string | Yes | Plain-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
| Status | Body | Meaning |
|---|---|---|
| 202 | 3 mails queued | Number of mails accepted for delivery. |
| 400 | recipients and body are required | No recipients, or empty body. |
/send/template
Like /send/bulk, but {{placeholders}} in the body are replaced with values from meta before sending.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
recipients | string[] | Yes | Recipient addresses. |
subject | string | No | Subject line. Placeholders are not replaced here. |
body | string | Yes | Body containing placeholders such as {{name}}. |
meta | object | No | Map 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 ofmeta.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
| Status | Body | Meaning |
|---|---|---|
| 202 | 2 templated mails queued | Number of mails accepted for delivery. |
| 400 | recipients and body are required | No recipients, or empty body. |
/health
No authLiveness probe for load balancers and container orchestrators. Returns 200 OK with the body ok while the process is running.
curl {{BASE}}/health
/api/status
No authService 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"
}
| Field | Values |
|---|---|
status | operational or degraded (database unreachable). |
database | ok or unavailable. |
delivery | The default server: smtp when one is configured, simulation when mails without a key-specific server are only logged. |
workers | Number of delivery workers. |
Errors
These can be returned by any authenticated endpoint. JSON errors always have an error string.
| Status | Error | What to do |
|---|---|---|
| 400 | Validation message (plain text) | Fix the request body. invalid JSON is also returned for bodies over 1 MB. |
| 401 | missing API key: … | Send the X-API-Key header. |
| 401 | invalid API key | Check for typos or whitespace; the key may have been deleted. |
| 403 | API key is disabled | Contact an administrator. |
| 403 | IP address … is not allowed for this API key | Call 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. |
| 500 | Queue error (plain text) | Retry later. |
| 500 | the SMTP settings for this API key could not be loaded; … | Contact an administrator. Nothing was counted against your limits. |
| 503 | authentication temporarily unavailable or could not verify usage limits, try again | The database is unreachable. Retry with backoff. |
{
"error": "invalid API key"
}
Delivery behaviour
202means 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
subjectare 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
deliveryissimulation, 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
- In the request form, tick Send through my own SMTP server and fill in the details below.
- An administrator reviews the request. If it is approved, they contact you by email to get the SMTP password privately.
- 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
| Setting | Required | Description |
|---|---|---|
| SMTP host | Yes | Your mail server, for example smtp.gmail.com or smtp.office365.com. |
| Port | No | 587 if left empty. Port 465 uses implicit TLS. Other ports use STARTTLS when the server offers it. |
| Username | No | The account used to sign in to the SMTP server. Leave empty if your server does not need a login. |
| Password | If a username is set | Never put it in the form. We ask for it privately after approval, and it is stored encrypted. |
| From address | Yes | The address your mail is sent from, for example no-reply@yourdomain.com. |
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"
}'
// Node.js 18+ (built-in fetch)
const BASE_URL = "{{BASE}}";
async function sendMail(to, subject, body) {
const res = await fetch(`${BASE_URL}/send`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.MAIL_API_KEY,
},
body: JSON.stringify({ to, subject, body }),
});
if (res.status === 202) return;
if (res.status === 429) {
const err = await res.json();
const wait = Number(res.headers.get("Retry-After") || 60);
throw new Error(`${err.error} (retry in ${wait}s)`);
}
const text = await res.text();
throw new Error(`Mail API ${res.status}: ${text}`);
}
await sendMail("ada@example.com", "Welcome", "Your account is ready.");
# pip install requests
import os
import requests
BASE_URL = "{{BASE}}"
def send_mail(to, subject, body):
res = requests.post(
f"{BASE_URL}/send",
headers={"X-API-Key": os.environ["MAIL_API_KEY"]},
json={"to": to, "subject": subject, "body": body},
timeout=10,
)
if res.status_code == 202:
return
if res.status_code == 429:
err = res.json()
wait = int(res.headers.get("Retry-After", 60))
raise RuntimeError(f"{err['error']} (retry in {wait}s)")
raise RuntimeError(f"Mail API {res.status_code}: {res.text}")
send_mail("ada@example.com", "Welcome", "Your account is ready.")
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const baseURL = "{{BASE}}"
func sendMail(to, subject, body string) error {
payload, _ := json.Marshal(map[string]string{"to": to, "subject": subject, "body": body})
req, err := http.NewRequest(http.MethodPost, baseURL+"/send", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("MAIL_API_KEY"))
client := &http.Client{Timeout: 10 * time.Second}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode == http.StatusAccepted {
return nil
}
msg, _ := io.ReadAll(res.Body)
if res.StatusCode == http.StatusTooManyRequests {
return fmt.Errorf("rate limited, retry after %ss: %s", res.Header.Get("Retry-After"), msg)
}
return fmt.Errorf("mail API %d: %s", res.StatusCode, msg)
}
func main() {
if err := sendMail("ada@example.com", "Welcome", "Your account is ready."); err != nil {
fmt.Println(err)
}
}
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
Never expose your key in browsers or mobile apps. Proxy requests through your backend.
Use environment variables or a secret manager, and keep keys out of logs and source control.
On 429, wait the number of seconds given before retrying. Don't retry in a tight loop.
Keep bulk requests within your hourly limit. A request larger than a limit can never succeed.
Ask for an IP allow list so a leaked key cannot be used from anywhere else.
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.