API v1 / complete guide

Boring on purpose.
Documented on purpose.

Everything needed to send mail, receive it through programmatic inboxes, poll messages, verify webhooks, or connect an MCP client.

01 / Overview

One inbox, two directions.

terriblemail exposes a tenant-scoped JSON API at https://api.terriblemail.com. Each server-generated inbox address can receive mail and can also be selected as the sender for outbound mail. Replies return to that same inbox.

The short version

Create an account, verify the signup address, create an inbox and a scoped API key in the dashboard, then use the key with REST or MCP. API keys never cross account boundaries.

REST/v1

Send, read, poll, and manage inboxes with JSON over HTTPS.

MCP/mcp

Use the same account and key through stateless Streamable HTTP.

SPEC/openapi.json

OpenAPI 3.1 for generators, validators, and tooling.

02 / Quickstart

Send your first message.

  1. Create and verify an account.Sign up, verify your email, and open the dashboard.
  2. Create an inbox.The service generates a readable address such as quiet-lantern-k7m2qd9x@terriblemail.com.
  3. Create an API key.Select the send scope. The plaintext key is shown once.
  4. Send.The from address must be an enabled inbox owned by the authenticated account.
curl https://api.terriblemail.com/v1/emails \
  -H 'Authorization: Bearer tm_live_REPLACE_ME' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: welcome-user-1842' \
  -d '{
    "from": "quiet-lantern-k7m2qd9x@terriblemail.com",
    "to": ["person@example.com"],
    "subject": "Hello from a tiny script",
    "text": "It worked."
  }'

A successful first request returns 201 Created. Repeating the same request with the same idempotency key within 24 hours returns the original message with 200 OK instead of sending twice.

03 / Authentication

Scoped Bearer keys.

Send the key on every protected REST or MCP request:

Authorization: Bearer tm_live_REPLACE_ME

Keys are displayed once and stored as SHA-256 hashes. A key may have an expiry, a per-minute ceiling, a recipient allowlist, and independent readable and writable inbox lists. Empty inbox lists mean all inboxes on the account.

ScopeAllows
sendSend from allowed account-owned inboxes.
account:readRead account identity, plan, current usage, and limits.
messages:readList and read parsed messages and create raw-message download links.
inboxes:readList inboxes visible to the key.
inboxes:writeCreate and permanently delete inboxes. Inbox-restricted keys cannot create new inboxes.

The service also enforces an account send ceiling by plan: Free 10, Developer 200, and Growth 1,000 requests per minute. A lower per-key limit wins. Use recipient and inbox restrictions for scripts and agents that do not need account-wide access.

04 / REST API

Endpoints at a glance.

Protected operations accept and return JSON. Unknown request fields are rejected. Unless an endpoint says otherwise, authenticate with a Bearer key and grant the required scope.

GET

/v1/account

account:read

Returns the authenticated account ID, verified email state, and effective plan. Billing and credential secrets are never included.

GET

/v1/usage

account:read

Returns usage counters and effective limits for the current quota period plus sender_reputation for the rolling seven-day permanent-bounce window. Paid periods match the current Stripe subscription item; Free uses a UTC calendar month.

POST

/v1/emails

send

Sends a transactional message from an enabled inbox owned by the account. At least one of text or html is required.

FieldTypeRules
fromemailRequired. Enabled, account-owned, and writable by the key.
toemail[]Required. 1–50 recipients. Free accounts may send only to the verified signup address.
subjectstringRequired. Maximum 998 characters.
textstringPlain-text body. Required when html is absent.
htmlstringHTML body. Required when text is absent.
headersobjectOptional X-* headers and validated List-Unsubscribe headers. CR/LF is rejected.
attachmentsarrayUp to 10 files and 5 MiB decoded total. Executable formats and path-like filenames are rejected.

Attachment object

{
  "filename": "invoice.pdf",
  "content_type": "application/pdf",
  "content_base64": "JVBERi0xLjQK...",
  "disposition": "attachment",
  "content_id": "optional-inline-id"
}

disposition is attachment by default or inline. Use content_id for inline content referenced by HTML.

Response

{
  "id": "msg_...",
  "status": "sent",
  "provider_id": "...",
  "created_at": "2026-07-18T12:00:00Z"
}
Idempotency and charging

Idempotency-Key is optional, limited to 200 characters, and retained for 24 hours. A request with N recipients consumes N recipient-delivery units and N copies of the estimated MIME size. Provider send failures refund both dimensions.

GET

/v1/messages

messages:read

Lists parsed inbound and outbound messages visible to the key. The default limit is 100.

QueryMeaning
inbox_idRepeat to select up to 50 readable inbox IDs.
directioninbound or outbound.
limit1–100; default 100.
cursorOpaque next_cursor for the next older page.
afterOpaque after_cursor for polling newer messages.

cursor and after are mutually exclusive. Ordinary pages are newest first. Polling with after is oldest first so a consumer can drain new messages without gaps.

curl 'https://api.terriblemail.com/v1/messages?inbox_id=inb_123&direction=inbound&limit=25' \
  -H 'Authorization: Bearer tm_live_REPLACE_ME'
{
  "data": [{ "id": "msg_...", "direction": "inbound", "status": "received" }],
  "has_more": false,
  "next_cursor": "opaque-older-position",
  "after_cursor": "opaque-newer-position"
}

Polling pattern

  1. Make an initial filtered request and save after_cursor.
  2. Repeat the same filters with after=....
  3. Process returned records in order, then replace the saved cursor with the returned after_cursor.
  4. If a poll returns more than the selected limit, continue until has_more is false.
GET

/v1/messages/{id}

messages:read

Returns one message visible to the key. Message objects can include direction, status, inbox_id, sender, recipients, subject, parsed text and HTML, safe headers, attachment metadata, spam and virus verdicts, size, and creation time. Cross-account or unreadable IDs return 404.

GET

/v1/messages/{id}/raw

messages:read

Returns an authenticated, five-minute download URL for the original inbound MIME object while raw content is retained.

{
  "url": "https://private-object-store.example/...",
  "expires_at": "2026-07-18T12:05:00Z"
}

Raw content is inbound-only. A quarantined message returns 409 quarantined_message; repeat with ?acknowledge_risk=1 only when you intend to inspect potentially unsafe content.

GET

/v1/inboxes

inboxes:read

Returns {"data":[...]} with inboxes visible to the key. Webhook signing secrets are never returned by list operations.

POST

/v1/inboxes

inboxes:write

Creates a globally unique, readable address that can both send and receive. The address itself is always generated by the service.

{
  "name": "Support agent",
  "webhook_url": "https://example.com/hooks/terriblemail"
}

name is an optional private reference label up to 80 characters; it does not change the address. webhook_url is optional and must be public HTTPS. The response contains inbox and a one-time webhook_secret beginning with tmwhsec_. Store the secret when a webhook is configured.

DELETE

/v1/inboxes/{id}

inboxes:write

Permanently deletes an account-owned inbox and returns 204 No Content. The key's writable inbox restrictions still apply.

GET

/healthz and /readyz

public

/healthz reports process health. /readyz also checks database readiness and returns 503 when unavailable. Neither endpoint requires authentication.

05 / Webhooks

Signed inbound delivery.

Set webhook_url while creating an inbox. terriblemail posts one of three event types when SES accepts mail for that inbox:

EventMeaning
message.receivedThe inbound message was accepted, parsed, and stored.
message.quarantinedMalware checks did not pass. Parsed content is omitted.
message.over_quotaThe accepted raw message exceeded an account quota. A recovery record remains available while retained.
{
  "id": "evt_...",
  "type": "message.received",
  "created_at": "2026-07-18T12:00:00Z",
  "data": { "message": { "id": "msg_...", "status": "received" } }
}

Verify the signature

The Terriblemail-Signature header has the form t=UNIX_SECONDS,v1=HEX_DIGEST. Compute HMAC-SHA256 with the inbox's one-time webhook secret over the exact string timestamp + "." + raw_request_body, compare the digest in constant time, and reject timestamps outside your replay window.

expected = HMAC_SHA256(webhook_secret, timestamp + "." + raw_body)
constant_time_compare(hex(expected), v1)

Return any 2xx response only after accepting the event. Delivery failures are retried by the inbound queue, so deduplicate by the stable event id. Webhook destinations must remain public HTTPS; private, loopback, link-local, unsafe DNS, and unsafe redirects are rejected.

06 / MCP server

Give an agent a mailbox.

The stateless Streamable HTTP endpoint is https://api.terriblemail.com/mcp. It uses JSON-RPC 2.0, negotiates MCP protocol version 2025-11-25, authenticates every call with the same Bearer keys as REST, and has no server-side session or SSE stream.

{
  "mcpServers": {
    "terriblemail": {
      "url": "https://api.terriblemail.com/mcp",
      "headers": {
        "Authorization": "Bearer tm_live_REPLACE_ME"
      }
    }
  }
}
ToolScopeInputs and behavior
send_emailsendfrom, to, subject, text/HTML, optional attachments and idempotency_key. Sends external email.
list_messagesmessages:readOptional inbox_ids, direction, limit, cursor, and after. Same paging semantics as REST.
get_messagemessages:readFetches one visible message by id.
list_inboxesinboxes:readLists inboxes visible to the key.
create_inboxinboxes:writeOptional private name and public HTTPS webhook_url. Hidden for inbox-restricted keys.
delete_inboxinboxes:writePermanently deletes the specified writable inbox.

tools/list advertises only operations granted by the key, and tools/call authorizes the operation again. Read and write inbox restrictions apply exactly as they do to REST. Clients should ask the user before consequential or sensitive sends.

07 / Quotas and retention

Hard stops, not surprise invoices.

  • Outbound units are recipient deliveries. One request to five recipients consumes five send units. Estimated MIME bytes are also charged once per recipient copy.
  • There are no overages. Recipient, outbound-data, inbound-message, inbound-data, inbox, and API-key limits are hard stops.
  • Paid and free periods differ. Paid usage follows the Stripe subscription item's period; Free uses UTC calendar months.
  • Plus-addressing belongs to the base inbox. Mail to address+tag@terriblemail.com routes to and consumes quota for address@terriblemail.com.
  • The first accepted over-quota inbound message remains visible. Its raw MIME can be recovered while retained, then inbound receipt is suspended until the quota period ends or a larger plan takes effect.
  • Downgrades do not delete resources. Excess newer inboxes and keys are disabled; an upgrade can re-enable retained resources.
  • Content retention defaults to 30 days. Parsed bodies, headers, attachments, raw pointers, and raw objects expire or are scrubbed. Remaining message metadata defaults to 90 days. Deployments may configure both periods.

Current allowance numbers are published on the pricing page and returned by GET /v1/usage.

08 / Errors

Consistent JSON failures.

{
  "error": {
    "code": "invalid_request",
    "message": "from must be an enabled inbox address owned by this account"
  }
}
StatusTypical meaning
400Invalid JSON, unknown fields, invalid parameters, or an unsafe webhook URL.
401Missing, invalid, expired, disabled, or unverified-account credential.
403Missing scope, inbox restriction, outbound suspension, or origin rejection.
404Resource absent, cross-account, or not readable by the key.
409Idempotency request still in progress or quarantined raw content requires acknowledgement.
429Rate, send, data, or inbox limit reached. Rate responses include Retry-After when applicable.
503Operational circuit breaker, dependency readiness failure, or raw recovery unavailable.

Do not automatically retry permanent 4xx failures. For transient 429 or 5xx responses, use bounded exponential backoff. Always reuse the same idempotency key when retrying a send whose result is unknown.

09 / Machine reference

Use the spec when code needs the answer.

This page is the complete human-readable guide. The canonical machine-readable contract is the OpenAPI 3.1 document, which includes request schemas, response schemas, parameter bounds, and required scopes.