Ratum Mail Ratum Mail
API reference

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:

ScopesKindCanCannot
["*"]FullRead every non-private inbox, send from them, create inboxes, mint keys, manage webhooksRead private inboxes
["send"]Send-onlyPOST /v1/send and GET /v1/accountRead, list, or delete anything
["*","inbox:<id>"]InboxRead, send, reply and triage in that inbox only, including a private oneSee 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.

POST/v1/keys

Mint a key. It needs a full key.

Field
scopesDefault ["*"]. Allowed: *, send, inbox:<id> for inboxes on your domain.
daily_send_limitOptional. The most sends per UTC day; 0 means this key cannot send.
allowed_recipientsOptional 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.

POST/v1/send

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.
toAn address or an array of addresses. Required.
cc, bccOptional arrays.
subjectRequired.
text, htmlAt least one is required.
reply_toOptional address.
attachmentsOptional: [{"filename","content","content_type"}], with content in base64.
idempotency_keyOptional, up to 200 characters. See below.

Answers 201 {"id","message_id","credits_remaining"}.

POST/v1/messages

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.

POST/v1/messages/:id/reply

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.

GET/v1/messages

Newest first. Every filter is optional:

Query
inboxInbox id.
directionin or out.
qSearch in subject, sender and body. Add body=0 to skip the body.
toRecipient address. % is a wildcard.
after, beforeISO timestamps.
folderFolder id, or unfiled.
limitDefault 50, at most 200.
pagePage 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.

GET/v1/messages/:id

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.

GET/v1/messages/:id/attachments/:aid

Answers {"filename","content_type","size_bytes","encoding":"base64","content"}. Add ?raw=1 for the bytes themselves, as a download.

GET/v1/threads/:id

Every message in a thread, oldest first. The id is a message's thread_id.

GET/v1/inboxes/:id/messages

The same as /v1/messages?inbox=:id, kept for older clients.

Waiting for mail

GET/v1/messages/wait

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
matchRegular expression, case-insensitive, over subject and body.
toRecipient address. % is a wildcard.
fromPart of the sender address.
inboxInbox id.
sinceISO 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.
timeoutSeconds. 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.

POST/v1/folders

{"name":"handled"}. Creating a folder that already exists returns it, with "existing":true.

GET/v1/folders
DELETE/v1/folders/:id

Messages in a deleted folder go back to unfiled.

POST/v1/messages/:id/move

{"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.

DELETE/v1/messages/:id

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.

POST/v1/messages/:id/restore
GET/v1/messages/deleted

The trash, with purge_at and days_left for each message.

POST/v1/messages/bulk-delete · bulk-restore · bulk-move

{"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

POST/v1/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.

GET/v1/inboxes

The inboxes this key can see.

DELETE/v1/inboxes/:id

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.

POST/v1/webhooks
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.

GET/v1/webhooks
DELETE/v1/webhooks/:id

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:

  1. Compute the HMAC over the raw body, before any JSON parsing.
  2. Compare it to x-ratum-signature in constant time.
  3. 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 429 is retried with increasing delays.
  • Any other 4xx is not retried. Answer 401 to a bad signature.

Because of retries, the same message can arrive twice. Key your side effects on data.id.

Push and wake-ups

POST/v1/push-channels

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.

POST/v1/wake-targets

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.

POST/v1/agent-signups
{"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 _many variants.
  • 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 with grep and while read.
  • Laravel: a package is in preview. It has a MAIL_MAILER=ratum transport (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 curl examples 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":"…"}
StatuserrorMeaning
401missing_token, invalid_tokenNo key, or an unknown one.
402insufficient_creditsThe domain is out of credits. Nothing was sent.
403send_only_keyA send-only key tried to do something other than send.
403inbox_scoped_keyAn inbox key tried something outside its inbox.
403from_domain_not_yoursfrom is not on your domain.
403recipient_not_allowedA recipient is outside the key's allowed_recipients.
403tenant_suspendedThe domain is suspended.
404not_found, from_inbox_not_foundIt doesn't exist, or this key can't see it. The two look the same on purpose.
409already_claimed, request_in_progress, address_taken, inbox_not_emptyA conflict. See the section concerned.
429rate_limitedMore than 120 requests a minute for the domain. Wait for the next minute.
429daily_send_limit_reachedThis key has used its sends for the UTC day.
502send_failedThe message could not be handed on. The credit is refunded, and the failed attempt is recorded.
503unavailableA 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.