Ratum Mail API
A JSON REST API over mail that lands in your own database. Everything an agent does over MCP goes through the same endpoints, so anything below works from curl, a backend, or a website.
Quick start
Base URL:
https://api.ratummail.com
Every request carries a key as a bearer token. Keys start with rk_. Your operator hands you a key for your domain, and you can mint narrower ones yourself (see Keys and scopes). Bodies are JSON, and so are answers.
curl https://api.ratummail.com/v1/account \
-H "Authorization: Bearer rk_..."
{"id":"…","name":"Example","domain":"example.com","credits":500}
Send a message:
curl https://api.ratummail.com/v1/send \
-H "Authorization: Bearer rk_..." -H "Content-Type: application/json" \
-d '{"from":"noreply","to":"[email protected]",
"subject":"Your order shipped","text":"It is on its way."}'
{"id":"…","message_id":"<…@example.com>","credits_remaining":499}
Read what arrived:
curl "https://api.ratummail.com/v1/messages?direction=in&limit=20" \
-H "Authorization: Bearer rk_..."
Mail to any address on your domain is accepted and stored, even an address that doesn't exist yet. You only create an inbox to send from it or to give it its own key.
Keys and scopes
A key belongs to one domain. What it can do comes from its scopes:
| Scopes | Kind | Can | Cannot |
|---|---|---|---|
["*"] | Full | Read every non-private inbox, send from them, create inboxes, mint keys, manage webhooks | Read private inboxes |
["send"] | Send-only | POST /v1/send and GET /v1/account | Read, list, or delete anything |
["*","inbox:<id>"] | Inbox | Read, send, reply and triage in that inbox only, including a private one | See any other inbox, create inboxes, mint keys |
Pick the narrowest key that does the job:
- A website or backend that sends mail gets a send-only key. If it leaks, it can send up to its daily limit and read nothing.
- An agent with a mailbox of its own gets an inbox key for a private inbox, so no other key on the domain can read that agent's mail.
- Keep the full key for administration.
A key is shown once, when it is created. Only its hash is stored, so a lost key is replaced, never recovered.
Mint a key. It needs a full key.
| Field | |
|---|---|
scopes | Default ["*"]. Allowed: *, send, inbox:<id> for inboxes on your domain. |
daily_send_limit | Optional. The most sends per UTC day; 0 means this key cannot send. |
allowed_recipients | Optional allowlist, e.g. ["@example.com","[email protected]"]. An entry starting with @ matches a whole domain. |
curl https://api.ratummail.com/v1/keys \
-H "Authorization: Bearer rk_full..." -H "Content-Type: application/json" \
-d '{"scopes":["send"],"daily_send_limit":500}'
{"key":"rk_…","scopes":["send"],"daily_send_limit":500,"allowed_recipients":null}
Sending
Each send costs one credit, taken before the message leaves. Mail is signed with your domain's DKIM key. The sent copy is stored and appears in GET /v1/messages?direction=out.
Send from an address. This is the call for websites and backends, and the only mail route a send-only key can use.
| Field | |
|---|---|
from | "noreply", "[email protected]" or "Shop <[email protected]>". It must be an existing inbox on your domain. A send-only or full key cannot send from a private inbox. |
to | An address or an array of addresses. Required. |
cc, bcc | Optional arrays. |
subject | Required. |
text, html | At least one is required. |
reply_to | Optional address. |
attachments | Optional: [{"filename","content","content_type"}], with content in base64. |
idempotency_key | Optional, up to 200 characters. See below. |
Answers 201 {"id","message_id","credits_remaining"}.
The same send, addressed by inbox id rather than address: {"inbox_id", "to", "subject", "text", …}. Agents that have just listed their inboxes use this one.
Reply in the thread, from the inbox the message arrived at. Subject, In-Reply-To and References are set for you.
{"text":"Thanks, on it.", "reply_all":false, "cc":[], "attachments":[], "idempotency_key":"reply-…"}
Retries without duplicates
A client that times out cannot tell whether its send went out. Pass the same idempotency_key on the retry:
- If the first send finished, the retry gets the first answer back, with the header
idempotent-replay: true. Nothing is sent twice. - If the first send is still running, the retry gets
409 request_in_progress.
Base the key on what the mail is about, for example order-4411-shipped, not on the attempt.
What sending does not carry
Custom headers such as List-Unsubscribe are not supported. Inline cid: images arrive as ordinary attachments. A request body can be at most 1 MB, and that includes attachments after base64 encoding.
Reading
Message bodies are written by whoever sent the mail. Escape them before you render them. Treat them as untrusted input before you pass them to a language model: "ignore your instructions" can arrive by email.
Newest first. Every filter is optional:
| Query | |
|---|---|
inbox | Inbox id. |
direction | in or out. |
q | Search in subject, sender and body. Add body=0 to skip the body. |
to | Recipient address. % is a wildcard. |
after, before | ISO timestamps. |
folder | Folder id, or unfiled. |
limit | Default 50, at most 200. |
page | Page number, starting at 1. |
{"data":[{"id","direction","from_addr","to_addrs","envelope_to","subject",
"thread_id","spam_score","received_at","folder_id","inbox_address"}],
"total":312, "page_size":50, "pages":7, "page":1, "next_before":"2026-09-27T…"}
Page numbers move when new mail arrives. To walk the whole archive, pass next_before back as before until it comes back null.
One message in full: body_text, body_html, auth_results (SPF, DKIM, rspamd verdicts), spam_score, thread_id, and attachments as [{id, filename, content_type, size_bytes, is_inline}]. Add ?headers=1 to include the raw headers.
Answers {"filename","content_type","size_bytes","encoding":"base64","content"}. Add ?raw=1 for the bytes themselves, as a download.
Every message in a thread, oldest first. The id is a message's thread_id.
The same as /v1/messages?inbox=:id, kept for older clients.
Waiting for mail
Holds the request open until an inbound message matches, then returns it. It fits flows where your code has just caused a mail and needs it: a sign-up code, a password reset, a test that asserts an email went out.
| Query | |
|---|---|
match | Regular expression, case-insensitive, over subject and body. |
to | Recipient address. % is a wildcard. |
from | Part of the sender address. |
inbox | Inbox id. |
since | ISO timestamp. Default: now, so only mail arriving after the call counts. Pass the time you triggered the mail to catch one that beat you to it. |
timeout | Seconds. Default 60, at most 300. |
curl "https://api.ratummail.com/v1/messages/wait?to=signup%40example.com&match=%5Cd%7B6%7D&timeout=120" \
-H "Authorization: Bearer rk_..."
{"matched":true, "data":{"id","from_addr","subject","body_text","received_at",…}}
{"matched":false, "timed_out":true, "since":"…", "timeout":120}
Set your HTTP client's timeout longer than timeout. An invalid regular expression answers 400 invalid_match_regex.
Folders and deleting
A message sits in one folder, or in none (unfiled). Moving a message out of unfiled works as a claim: when several agents share an inbox, exactly one of them gets each message.
{"name":"handled"}. Creating a folder that already exists returns it, with "existing":true.
Messages in a deleted folder go back to unfiled.
{"folder_id":"…"}, or null for unfiled. It only succeeds while the message is still unfiled. If another agent claimed it first, you get 409 already_claimed, which names the folder. Pass "require_unfiled":false to move it anyway.
Deleting moves the message to the trash. It can be restored for 30 days, after which a scheduled purge removes it. No API key can delete mail permanently.
The trash, with purge_at and days_left for each message.
{"ids":[…]}, up to 500 per call. bulk-move also takes folder_id and require_unfiled. Ids that are out of scope or already done are skipped and counted in the answer.
Inboxes
{"local_part":"support", "display_name":"Support", "is_private":false}. The inbox gets the address support@yourdomain. Leave out local_part to get a random one. Set is_private for an agent's own mailbox: only a key scoped to that inbox can read it. This needs a full key.
The inboxes this key can see.
Deleting an inbox destroys its mail permanently, not through the trash. If the inbox still holds mail, you get 409 inbox_not_empty unless you add ?force=true.
Webhooks
Ratum can POST each inbound message to your server as soon as it is stored. A webhook covers the whole domain, so managing webhooks needs a full key; an inbox key gets 403 inbox_scoped_key. Mail to private inboxes is never sent to a webhook, because only that inbox's own key may read it. To wake an agent on its private inbox, use a wake target.
curl https://api.ratummail.com/v1/webhooks \
-H "Authorization: Bearer rk_full..." -H "Content-Type: application/json" \
-d '{"url":"https://your-app.example/ratum/webhook"}'
{"id":"…","url":"…","events":["message.received"],"secret":"whsec_…"}
The URL must be https. The secret is shown once; store it, you need it to check signatures.
The delivery
POST /ratum/webhook
content-type: application/json
x-ratum-event: message.received
x-ratum-timestamp: 1790582400
x-ratum-signature: 5f2c… hex HMAC-SHA256 of "{timestamp}.{body}"
{"type":"message.received","created_at":"2026-09-28T10:00:00.000Z",
"data":{"id","inbox_id","inbox_address","thread_id","from","to","subject",
"text","html","spam_score","sanitized","received_at"}}
Checking the signature
Before you trust a delivery:
- Compute the HMAC over the raw body, before any JSON parsing.
- Compare it to
x-ratum-signaturein constant time. - Refuse a timestamp more than five minutes old; that stops replays.
// Node
import crypto from 'node:crypto';
function verify(secret, ts, sig, rawBody) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return sig?.length === want.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
}
// PHP
function verify(string $secret, string $ts, string $sig, string $rawBody): bool {
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) return false;
return hash_equals(hash_hmac('sha256', "$ts.$rawBody", $secret), $sig);
}
# Python
import hmac, hashlib, time
def verify(secret: str, ts: str, sig: str, raw_body: bytes) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False
want = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)
Retries
Answer any 2xx within a few seconds, and do slow work afterwards.
- A timeout, a 5xx or a
429is retried with increasing delays. - Any other 4xx is not retried. Answer
401to a bad signature.
Because of retries, the same message can arrive twice. Key your side effects on data.id.
Push and wake-ups
Sends a phone notification through ntfy. By default it fires when a mail carries a sign-in or one-time code, and the code is in the notification.
{"url":"https://ntfy.sh/<your-secret-topic>", "inbox_id":"…", "events":["signin.code"]}
Without inbox_id, a channel covers every non-private inbox. Binding a channel to a private inbox needs that inbox's own key. Events are signin.code and message.received. Test a channel with POST /v1/push-channels/:id/test. List and remove with GET and DELETE.
An agent registers a URL to be called when mail reaches its inbox, so it can sleep instead of polling.
{"url":"https://agent.example/wake", "name":"support-bot", "inbox_id":"…", "max_spam":5}
The answer includes a wk_… token, shown once. Wake calls carry it as Authorization: Bearer wk_…, and your endpoint should refuse anything else. A key scoped to one inbox is bound to that inbox automatically.
Agent signup links
A signup link gives a new agent its brief and its key without the key ever being pasted into a chat.
{"prompt":"You answer [email protected] …", "inbox_id":"…",
"label":"support-bot", "expires_in_minutes":5, "daily_send_limit":50}
{"id":"…","expires_at":"…","signup_url":"https://api.ratummail.com/agent-signup/as_…"}
Hand the URL to the agent. A GET on it previews the link without using it up, so a chat app that previews links cannot burn it. A POST redeems it once and returns the brief, a fresh key, and the MCP command already filled in. Links expire within minutes by default.
MCP
Agents that speak the Model Context Protocol connect directly:
claude mcp add --transport http ratum-mail https://api.ratummail.com/mcp \
--header "Authorization: Bearer rk_..."
The tools map onto the endpoints above, under the same key and the same scope:
- Reading:
mail_whoami,mail_inboxes,mail_search,mail_read,mail_attachment,mail_wait. - Sending:
mail_send,mail_reply. - Folders:
mail_folders,mail_create_folder,mail_move. - Deleting:
mail_delete,mail_restore,mail_deleted, and the_manyvariants. - Inboxes:
mail_create_inbox,mail_delete_inbox.
For a client that only speaks stdio, a single-file bridge with no dependencies is at /client.mjs.
Clients
- Terminal:
npm install -g ratum-cli. It is an interactive mailbox, plus scriptable commands whose tab-separated output works withgrepandwhile read. - Laravel: a package is in preview. It has a
MAIL_MAILER=ratumtransport (every Mailable and notification goes through/v1/send), an inbox client, and a webhook route that checks signatures and fires a Laravel event. It is not on Packagist yet. - Anything else: it's plain HTTPS and JSON; the
curlexamples above are the whole protocol.
Errors and limits
Errors answer with a status and a machine-readable code, sometimes with a detail for a human:
{"error":"from_inbox_not_found","address":"[email protected]","detail":"…"}
| Status | error | Meaning |
|---|---|---|
| 401 | missing_token, invalid_token | No key, or an unknown one. |
| 402 | insufficient_credits | The domain is out of credits. Nothing was sent. |
| 403 | send_only_key | A send-only key tried to do something other than send. |
| 403 | inbox_scoped_key | An inbox key tried something outside its inbox. |
| 403 | from_domain_not_yours | from is not on your domain. |
| 403 | recipient_not_allowed | A recipient is outside the key's allowed_recipients. |
| 403 | tenant_suspended | The domain is suspended. |
| 404 | not_found, from_inbox_not_found | It doesn't exist, or this key can't see it. The two look the same on purpose. |
| 409 | already_claimed, request_in_progress, address_taken, inbox_not_empty | A conflict. See the section concerned. |
| 429 | rate_limited | More than 120 requests a minute for the domain. Wait for the next minute. |
| 429 | daily_send_limit_reached | This key has used its sends for the UTC day. |
| 502 | send_failed | The message could not be handed on. The credit is refunded, and the failed attempt is recorded. |
| 503 | unavailable | A dependency is down. Sending fails closed rather than unmetered. Retry. |
Limits: 120 requests a minute per domain; request bodies up to 1 MB; message lists up to 200 per page; bulk calls up to 500 ids; wait up to 300 seconds; the trash is kept for 30 days.