Multi-tenant outbound email API with templates, campaigns, webhooks, scheduling, and MCP server.
/api/v1/openapi.json with your API key.Email freepass@mail.rodmena.co.uk from any real address and you'll get a free API key back automatically — no signup form. Free tier: 5 emails/second and 500 emails/day. Lost or leaked your key? Email freepass+rotate@mail.rodmena.co.uk from the same address to replace it. Need higher limits? Email sales@mail.rodmena.co.uk.
curl -X POST https://mailserver.rodmena.co.uk/api/v1/emails \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-d '{
"to": ["recipient@example.com"],
"subject": "Hello from Rodmena Mail API",
"html": "<h1>It works!</h1><p>Sent via the API.</p>",
"type": "transactional"
}'
Returns: {"track_id": "01H...", "status": "accepted", "eligible_start": "..."}
All endpoints require Authorization: Bearer <uuid4-api-key>. API keys are UUIDv4 tokens issued via the admin CLI and registered in auth.rodmena.co.uk.
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/emails | Send a transactional email |
| GET | /api/v1/emails/{track_id} | Get email status |
| GET | /api/v1/emails | List emails |
| GET | /api/v1/emails/{track_id}/events | Full event history |
| POST | /api/v1/campaigns | Create a bulk campaign |
| GET | /api/v1/campaigns/{campaign_id} | Get campaign status |
| POST | /api/v1/campaigns/{campaign_id}/cancel | Cancel a campaign |
| POST/GET/PATCH/DELETE | /api/v1/templates | Template CRUD |
| POST | /api/v1/templates/{id}/preview | Preview a template |
| GET/PUT | /api/v1/tenant/quotas | Manage quotas |
| GET/PUT | /api/v1/tenant/config | Manage tenant config |
| GET/DELETE | /api/v1/suppression | Suppression list |
| POST | /api/v1/emails/{id}/cancel | Cancel a queued/scheduled email |
| GET | /api/v1/inbound | Received mail (metadata only) |
| GET | /api/v1/inbound/{id} | One received message, with bodies |
| POST | /api/v1/inbound/{id}/ack | Mark a received message handled |
| GET | /api/v1/sent | Your sent folder — did I send X? (metadata only, no bodies) |
| GET | /api/v1/inbound/{id}/attachments | Attachments on a received message |
| GET | /api/v1/inbound/{id}/attachments/{index} | One attachment's bytes |
| GET | /api/v1/openapi.json | OpenAPI schema (authenticated) |
| POST/GET/PATCH/DELETE | /api/v1/webhooks | Webhook endpoint CRUD |
| POST | /api/v1/webhooks/{id}/test | Test a webhook |
| POST | /api/v1/webhooks/{id}/rotate-secret | Rotate webhook secret |
| GET | /api/v1/webhooks/{id}/deliveries | Delivery history |
| POST | /api/v1/webhooks/deliveries/{id}/redeliver | Redeliver |
| GET | /api/v1/audit | Audit log |
| GET | /health | Health check |
| GET | /metrics | Prometheus metrics |
| Event | Description |
|---|---|
| email.accepted | Email accepted into the system |
| email.rendered | Template rendered (template sends only). Its detail carries the template_version that produced the message |
| email.scheduled | Email scheduled for future dispatch |
| email.sending | SMTP send in progress |
| email.sent | The strongest positive signal we have — our MTA accepted the message (250) |
| email.delivered | Never emitted. We relay onward and downstream providers do not report delivery back to us, so delivered_at is always null. Treat sent without a later bounced as success. |
| email.bounced | Bounce received |
| email.complained | Spam complaint received |
| email.failed | Dispatch failed |
| email.cancelled | Email cancelled |
| email.suppressed | Recipient on suppression list |
| email.inbound | An inbound message was received for your tenant address |
| campaign.* | Campaign lifecycle events |
Aliases: email.terminal, campaign.terminal. Wildcards: email.*, campaign.*, *.
email.terminal does NOT include email.sent. It expands to
delivered, bounced, complained, failed,
cancelled — and since email.delivered is never emitted (see above), in
practice you receive failures only. If you subscribe to email.terminal
expecting to hear that a message succeeded, you will never be called. Subscribe to
email.sent explicitly for the success signal, or use email.* for
everything. This surprised us in our own testing: a probe subscribed to
email.terminal on a message that then succeeded produced zero deliveries, which
reads exactly like a broken outbox.
import hmac, hashlib
def verify(signature_header, timestamp, body, secret):
expected = "sha256=" + hmac.new(
secret.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature_header, expected)
The timestamp arrives as X-MailApi-Timestamp (unix seconds) and is inside
the signed message, not merely a sibling header — so it cannot be altered without
invalidating the signature. That is what makes a freshness check worth doing: verify the HMAC
first, then reject if abs(now - timestamp) exceeds your window.
Every delivery attempt is re-signed with the time of that attempt, so a retry
carries a fresh timestamp rather than the original. Size your window on clock skew plus
transit — minutes, not hours. A signature is only ever a few seconds old when it reaches
you, whatever attempt number it is. X-MailApi-Attempt carries the 1-based attempt
number if you want to record it.
The retry backoff (10s, 60s, 300s, 1800s, 3600s; 5 attempts) changes when an attempt is sent, not how old its timestamp is when it arrives, so it is not an input to your window. An earlier version of this page told you to keep the window above the 3600s retry horizon. That was wrong, and self-contradictory in the same sentence: it is only true of a sender that reuses the original timestamp across retries, which is precisely what we do not do. Following it would have meant holding a replay window open for an hour for no reason. Corrected 2026-07-26 after RunFlow pointed out that both halves could not be true.
A tight window is a promise about YOUR clock, not ours. Sizing on skew moves
the constraint from our system to yours: at an hour, your host can drift a long way before
anything breaks; at five minutes, five minutes of drift rejects every delivery we send — a silent
401 that reads as a secret mismatch, with no external party positioned to notice. So pick minutes
only if you actually measure your skew and alert on it. If you do not, a larger window is the
safer default and costs you very little, because replay of a delivery you have already processed
should be caught by de-duplicating on X-MailApi-Event-Id rather than by the clock.
This paragraph exists because RunFlow made the point after we told them to tighten.
A rejection is not a retried tail. We classify any 4xx response (including a
401 from a failed HMAC or a freshness-window check) as permanent and do not retry it — the
delivery goes dead on the first attempt, which is why a dead row reads attempts: 0.
The re-signing guarantee above covers only the classes we DO retry (408, 429, 5xx): those retries
carry a fresh timestamp. So a freshness-window rejection is a single lost delivery, not something a
re-signed retry will recover. Size your window to avoid rejecting — not in the expectation
that a tighter window is safe because retries are fresh. A 401 from a genuine secret mismatch (a
rotation applied on one side only) is permanent by the same rule, which is the safe default:
retrying a wrong-secret 401 would only loop until the horizon and then die anyway. Recorded here
after RunFlow pointed out that the re-signing text implied a rejected delivery would be retried.
We will publish a changelog entry before the re-signing behaviour or the retry horizon changes.
The platform promises at least once delivery of accepted messages
(SMTP_RECOVERY_SEMANTICS). If our process is interrupted after the receiving mail server accepted a message but before we recorded it as sent, the outcome is unknowable from our side, so the message is re-sent. The result: we never silently lose a message, but an abnormal termination can deliver one duplicate, bounded by the dispatch attempt cap. Treat transactional mail as idempotent — every message carries a deterministic Message-ID to de-duplicate on.
Register the MCP server at https://mailserver.rodmena.co.uk/mcp with your API key as the Bearer token.
Templates use Jinja2 in a SandboxedEnvironment. All client templates are untrusted — no import, no dunder access, no OS paths.
Optional variables must be guarded. Templates render strictly by default, and
strictly always for transactional mail. Under strict rendering a bare truthiness test
raises on a variable that was not supplied, so an optional variable silently becomes required and
the send returns 422 instead of going out:
{% if flat %}Flat {{ flat }}{% endif %} → 422 when flat is absent
{% if flat is defined and flat %}Flat {{ flat }}{% endif %} → correct
Use is defined for every variable you do not always supply. A failing
POST /api/v1/templates/{id}/preview names the undefined variables, so rendering each
template against only its required variables in CI will catch a load-bearing "optional" one.
A blank value is refused too, for transactional mail. Supplying
code="" used to deliver "Your code: " as a success; a bare {{ x }} filled
with an empty or whitespace-only string is now a 422. So:
A bare {{ x }} is a decision that x may block the send. Make it
bare when a blank value should stop the message, and guard it otherwise. Use it on the
one-time code; guard the greeting. Otherwise a cosmetic field ends up gating a
security-critical one — a resident with no name on file refused a valid sign-in code.
{{ code }} → blank stops the send (correct for a code)
{{ name | default('there', true) }} → blank is fine, renders "there"
{% if expires %}...{% endif %} → blank omits the sentence
Guarded and defaulted forms are never refused, and marketing is unaffected.
Preview reports blank_variables and would_reject_as_transactional.
Because a bare {{ x }} is a decision, it is worth enforcing rather than
remembering. This pattern is contributed by America House ISP, who found a
cosmetic greeting gating a security-critical code in their own template. List the variables
whose blankness must stop the message, then probe each variable in the preview context
with "" and check both directions:
blank_must_reject = ["code"] # safety-critical: a blank must stop the send
for variable in preview_context:
probe = {**preview_context, variable: ""}
rejects = preview(probe).would_reject_as_transactional
if variable in blank_must_reject and not rejects:
fail(f"{variable} lost its protection") # someone 'tidied' {{ code }} into a default
if variable not in blank_must_reject and rejects:
fail(f"cosmetic {variable} can block the send") # a greeting gating a sign-in code
The second direction is the one people do not think of, and is the one that bites. Note the caveat: this probes only variables present in your preview context, so also assert that the template renders with its required variables alone.
| Type | Allowed Window |
|---|---|
| transactional | Any time |
| notification | Any time |
| marketing | Mon-Fri 09:00-17:00, excluding UK holidays |
| legal | Mon-Fri 09:00-17:00, excluding UK holidays |
| Limit | Free | Paid |
|---|---|---|
Send burst (mail-std-burst) | 5/sec | 100/sec |
Send daily (mail-std-daily) | 500/day | 50,000/day |
Template preview (mail-preview-burst) | 30/sec — metered separately, so previewing never spends send quota | |
| Idempotency key retention | 24 hours | |
| Inbound attachments per message | 20 parts, 10 MB total | |
| Request body | 30 MB | |
GET /api/v1/tenant/quotas/usage is authoritative for your own tenant and shows
what you have left. The idempotency window matters if you key on something date-scoped: after
24 hours the same key is a new send, not a duplicate.
Behaviour changes, newest first. Route changes also show up in
GET /api/v1/openapi.json; the ones below do not, because they change how an
existing route behaves.
| Date | Change |
|---|---|
| 2026-08-02 | New: GET /api/v1/sent — your sent folder. Answer "did I send X?" from the product's own interface (filters recipient, since, track_id/message_id), tenant-scoped, metadata only — never the message body. The outbound mirror of ?consumed_since= on /api/v1/inbound (#257). |
| 2026-08-02 | Documented (no behaviour change): the delivery guarantee is now a stated promise. If the process is interrupted after the receiving server accepted a message but before sent was recorded, the outcome is unknowable from our side; the platform re-sends (at-least-once) — one duplicate at most, bounded by the dispatch attempt cap, never a silent loss. This page states whichever SMTP_RECOVERY_SEMANTICS the deployment is configured with. |
| 2026-07-27 | Documented (no behaviour change): email.terminal does not include email.sent. The alias expands to the failure terminals plus email.delivered, which is never emitted — so subscribing to email.terminal and waiting for a success notification means waiting forever. Subscribe to email.sent explicitly, or email.*. Documented after it cost us time in our own testing, and pinned by a test so it cannot drift silently. |
| 2026-07-27 | GET /api/v1/emails/{track_id}/deliveries lists WEBHOOK DELIVERY ATTEMPTS, not per-recipient email deliveries. It is empty whenever no webhook subscription matched that send, and its rows carry no recipient address. For the recipients of a message use GET /api/v1/emails/{track_id} (to/to_addrs, cc, bcc). Stated because we gave an integrator the wrong answer on this and they caught it. |
| 2026-07-27 | The idempotency_key dedup window is now described in the OpenAPI schema. The 24h figure was correct but readable only in prose; the field now carries it (plus the 48h durable backstop that keeps dedup working across a Redis flush). Purely additive — no behaviour change. |
| 2026-07-27 | A 4xx webhook response (incl. a 401 from a failed HMAC or freshness check) is permanent and is not retried — the delivery dies on the first attempt (attempts: 0). The re-signing guarantee covers only the retryable classes (408, 429, 5xx). So a freshness-window rejection is a single lost delivery, not a re-signed retry away from recovery: size your window to avoid rejecting. Stated after RunFlow observed every dead row at attempts: 0 and asked whether a 401 was meant to be terminal. |
| 2026-07-26 | Correction: the webhook signature guidance previously said to size a freshness window above the 3600s retry horizon. That was wrong — every attempt is re-signed at send time, so a signature is only ever seconds old on arrival and the window should be minutes. Following the old text meant holding a replay window open for an hour needlessly. |
| 2026-07-26 | Error responses are described too. detail is an array for request-validation failures, a string for a domain rejection (no_subject), or an object for a template render failure — all three are now in the schema, where it previously claimed only the array existed. Match a string detail on the part before the first : or ;. |
| 2026-07-26 | GET /api/v1/emails returns 400 for an unrecognised status or type value, naming the supported set. It previously answered 200 with an empty list, so a typo looked like a quiet period. |
| 2026-07-26 | Every success response is now described in the OpenAPI document (47/47, was 2/47) — previously it typed request bodies only, so a pinned copy could not show a response-shape change. Deprecated spellings (from/to/type, text_body/html_body) are declared alongside the canonical ones, so retiring them will be a visible schema change. |
| 2026-07-26 | GET /api/v1/webhooks/deliveries/{id} no longer returns inline_secret_encrypted, and campaign records no longer return the encrypted webhook secret. Ciphertext you cannot decrypt and never needed. |
| 2026-07-26 | PATCH /api/v1/webhooks/{id} returns the same shape whether or not anything changed, plus an updated boolean. It previously returned two different shapes. |
| 2026-07-26 | GET /api/v1/emails/{track_id} reports the attachments that were sent with a message (metadata only). Previously the API could not say what it had sent. |
| 2026-07-26 | For transactional mail a bare {{ x }} supplied as an empty or whitespace-only string is a 422, not a delivered blank. Guarded and defaulted forms are unaffected. The same type-strictness now applies to POST /api/v1/campaigns, which had been rendering transactional campaigns leniently. |
| 2026-07-26 | A failing template preview names the undefined variables and how to guard them. |
| 2026-07-26 | email.rendered and email.suppressed now fan out to subscribed webhooks (previously recorded in the event history only). Subscribing to an event that cannot fire returns a warnings array naming it. |
| 2026-07-26 | GET /api/v1/emails accepts template_id as a filter, and now returns 400 for an unsupported query parameter instead of ignoring it. |
| 2026-07-26 | An authenticated request for a route that does not exist returns 404 (and 405 for a wrong method) instead of 403. Anonymous callers still get 401 for every path. |
| 2026-07-26 | Inbound attachment bytes are retrievable at GET /api/v1/inbound/{id}/attachments/{index}. storage_path is no longer returned on attachment metadata; use the index. |
| 2026-07-26 | transactional sends render strictly — subject and body — whatever the template's strict_mode says. A missing variable is a 422 rather than a delivered blank. Optional variables need is defined (see Templates). |
| 2026-07-26 | Templates are compiled and trial-rendered at create/update, so a broken one is a 422 at write time rather than at first send. |
| 2026-07-26 | POST /api/v1/emails/{track_id}/cancel cancels a single queued or scheduled message. |
| 2026-07-26 | mail.suppression.read is included on paid tiers, so a tenant can see why an address stopped receiving mail. |