Rodmena Mail API v0.1

Multi-tenant outbound email API with templates, campaigns, webhooks, scheduling, and MCP server.

🤖 Using Claude Code or an AI agent? — point it at /api/v1/openapi.json with your API key.

Get a free 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.

Quick Start

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": "..."}

Authentication

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.

Endpoints

MethodPathDescription
POST/api/v1/emailsSend a transactional email
GET/api/v1/emails/{track_id}Get email status
GET/api/v1/emailsList emails
GET/api/v1/emails/{track_id}/eventsFull event history
POST/api/v1/campaignsCreate a bulk campaign
GET/api/v1/campaigns/{campaign_id}Get campaign status
POST/api/v1/campaigns/{campaign_id}/cancelCancel a campaign
POST/GET/PATCH/DELETE/api/v1/templatesTemplate CRUD
POST/api/v1/templates/{id}/previewPreview a template
GET/PUT/api/v1/tenant/quotasManage quotas
GET/PUT/api/v1/tenant/configManage tenant config
GET/DELETE/api/v1/suppressionSuppression list
POST/api/v1/emails/{id}/cancelCancel a queued/scheduled email
GET/api/v1/inboundReceived mail (metadata only)
GET/api/v1/inbound/{id}One received message, with bodies
POST/api/v1/inbound/{id}/ackMark a received message handled
GET/api/v1/sentYour sent folder — did I send X? (metadata only, no bodies)
GET/api/v1/inbound/{id}/attachmentsAttachments on a received message
GET/api/v1/inbound/{id}/attachments/{index}One attachment's bytes
GET/api/v1/openapi.jsonOpenAPI schema (authenticated)
POST/GET/PATCH/DELETE/api/v1/webhooksWebhook endpoint CRUD
POST/api/v1/webhooks/{id}/testTest a webhook
POST/api/v1/webhooks/{id}/rotate-secretRotate webhook secret
GET/api/v1/webhooks/{id}/deliveriesDelivery history
POST/api/v1/webhooks/deliveries/{id}/redeliverRedeliver
GET/api/v1/auditAudit log
GET/healthHealth check
GET/metricsPrometheus metrics

Webhook Events

EventDescription
email.acceptedEmail accepted into the system
email.renderedTemplate rendered (template sends only). Its detail carries the template_version that produced the message
email.scheduledEmail scheduled for future dispatch
email.sendingSMTP send in progress
email.sentThe strongest positive signal we have — our MTA accepted the message (250)
email.deliveredNever 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.bouncedBounce received
email.complainedSpam complaint received
email.failedDispatch failed
email.cancelledEmail cancelled
email.suppressedRecipient on suppression list
email.inboundAn 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.

Webhook Signature Verification

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.

Delivery Guarantee

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.

MCP Server

Register the MCP server at https://mailserver.rodmena.co.uk/mcp with your API key as the Bearer token.

Templates

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.

Asserting it in your deploy

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.

Calendar Rules

TypeAllowed Window
transactionalAny time
notificationAny time
marketingMon-Fri 09:00-17:00, excluding UK holidays
legalMon-Fri 09:00-17:00, excluding UK holidays

Limits

LimitFreePaid
Send burst (mail-std-burst)5/sec100/sec
Send daily (mail-std-daily)500/day50,000/day
Template preview (mail-preview-burst)30/sec — metered separately, so previewing never spends send quota
Idempotency key retention24 hours
Inbound attachments per message20 parts, 10 MB total
Request body30 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.

Changelog

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.

DateChange
2026-08-02New: 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-02Documented (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-27Documented (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-27GET /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-27The 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-27A 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-26Correction: 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-26Error 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-26GET /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-26Every 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-26GET /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-26PATCH /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-26GET /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-26For 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-26A failing template preview names the undefined variables and how to guard them.
2026-07-26email.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-26GET /api/v1/emails accepts template_id as a filter, and now returns 400 for an unsupported query parameter instead of ignoring it.
2026-07-26An 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-26Inbound 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-26transactional 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-26Templates 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-26POST /api/v1/emails/{track_id}/cancel cancels a single queued or scheduled message.
2026-07-26mail.suppression.read is included on paid tiers, so a tenant can see why an address stopped receiving mail.