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.
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.
/v1Send, read, poll, and manage inboxes with JSON over HTTPS.
/mcpUse the same account and key through stateless Streamable HTTP.
/openapi.jsonOpenAPI 3.1 for generators, validators, and tooling.
Send your first message.
- Create and verify an account.Sign up, verify your email, and open the dashboard.
- Create an inbox.The service generates a readable address such as
quiet-lantern-k7m2qd9x@terriblemail.com. - Create an API key.Select the
sendscope. The plaintext key is shown once. - Send.The
fromaddress 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.
Scoped Bearer keys.
Send the key on every protected REST or MCP request:
Authorization: Bearer tm_live_REPLACE_MEKeys 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.
| Scope | Allows |
|---|---|
send | Send from allowed account-owned inboxes. |
account:read | Read account identity, plan, current usage, and limits. |
messages:read | List and read parsed messages and create raw-message download links. |
inboxes:read | List inboxes visible to the key. |
inboxes:write | Create 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.
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.
/v1/accountaccount:readGET/v1/usageaccount:readPOST/v1/emailssendGET/v1/messagesmessages:readGET/v1/messages/{id}messages:readGET/v1/messages/{id}/rawmessages:readGET/v1/inboxesinboxes:readPOST/v1/inboxesinboxes:writeDELETE/v1/inboxes/{id}inboxes:write/v1/account
account:readReturns the authenticated account ID, verified email state, and effective plan. Billing and credential secrets are never included.
/v1/usage
account:readReturns 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.
/v1/emails
sendSends a transactional message from an enabled inbox owned by the account. At least one of text or html is required.
| Field | Type | Rules |
|---|---|---|
from | Required. Enabled, account-owned, and writable by the key. | |
to | email[] | Required. 1–50 recipients. Free accounts may send only to the verified signup address. |
subject | string | Required. Maximum 998 characters. |
text | string | Plain-text body. Required when html is absent. |
html | string | HTML body. Required when text is absent. |
headers | object | Optional X-* headers and validated List-Unsubscribe headers. CR/LF is rejected. |
attachments | array | Up 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-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.
/v1/messages
messages:readLists parsed inbound and outbound messages visible to the key. The default limit is 100.
| Query | Meaning |
|---|---|
inbox_id | Repeat to select up to 50 readable inbox IDs. |
direction | inbound or outbound. |
limit | 1–100; default 100. |
cursor | Opaque next_cursor for the next older page. |
after | Opaque 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
- Make an initial filtered request and save
after_cursor. - Repeat the same filters with
after=.... - Process returned records in order, then replace the saved cursor with the returned
after_cursor. - If a poll returns more than the selected limit, continue until
has_moreis false.
/v1/messages/{id}
messages:readReturns 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.
/v1/messages/{id}/raw
messages:readReturns 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.
/v1/inboxes
inboxes:readReturns {"data":[...]} with inboxes visible to the key. Webhook signing secrets are never returned by list operations.
/v1/inboxes
inboxes:writeCreates 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.
/v1/inboxes/{id}
inboxes:writePermanently deletes an account-owned inbox and returns 204 No Content. The key's writable inbox restrictions still apply.
/healthz and /readyz
public/healthz reports process health. /readyz also checks database readiness and returns 503 when unavailable. Neither endpoint requires authentication.
Signed inbound delivery.
Set webhook_url while creating an inbox. terriblemail posts one of three event types when SES accepts mail for that inbox:
| Event | Meaning |
|---|---|
message.received | The inbound message was accepted, parsed, and stored. |
message.quarantined | Malware checks did not pass. Parsed content is omitted. |
message.over_quota | The 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.
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"
}
}
}
}| Tool | Scope | Inputs and behavior |
|---|---|---|
send_email | send | from, to, subject, text/HTML, optional attachments and idempotency_key. Sends external email. |
list_messages | messages:read | Optional inbox_ids, direction, limit, cursor, and after. Same paging semantics as REST. |
get_message | messages:read | Fetches one visible message by id. |
list_inboxes | inboxes:read | Lists inboxes visible to the key. |
create_inbox | inboxes:write | Optional private name and public HTTPS webhook_url. Hidden for inbox-restricted keys. |
delete_inbox | inboxes:write | Permanently 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.
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.comroutes to and consumes quota foraddress@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.
Consistent JSON failures.
{
"error": {
"code": "invalid_request",
"message": "from must be an enabled inbox address owned by this account"
}
}| Status | Typical meaning |
|---|---|
400 | Invalid JSON, unknown fields, invalid parameters, or an unsafe webhook URL. |
401 | Missing, invalid, expired, disabled, or unverified-account credential. |
403 | Missing scope, inbox restriction, outbound suspension, or origin rejection. |
404 | Resource absent, cross-account, or not readable by the key. |
409 | Idempotency request still in progress or quarantined raw content requires acknowledgement. |
429 | Rate, send, data, or inbox limit reached. Rate responses include Retry-After when applicable. |
503 | Operational 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.
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.