# fixture.email — Email Testing API > fixture.email is hosted email-testing infrastructure for developers and the AI agents that build with them: create disposable mailboxes on a real domain, send or trigger email, then assert on subjects, bodies, links, headers and SPF/DKIM/DMARC results. Drive it over this HTTP API or the fixture.email MCP server. AI-native email testing, built for agents — the AI-first alternative to Mailosaur. ## Start here — connect your agent - **MCP endpoint (recommended for agents)**: `https://mcp.fixture.email/mcp` — remote streamable HTTP, no transport session and no handshake. OAuth-capable clients can add this URL without a static header and sign in as the account owner; clients that accept explicit headers can use an org-scoped `X-API-Key`. - **API-key clients, one command**: `claude mcp add --transport http fixture-email https://mcp.fixture.email/mcp --header "X-API-Key: $KEY"` (Cursor and Codex config below, under Getting Started). - **This document (full HTTP API reference)**: https://fixture.email/llms.txt — public, no key required. - **Human-readable docs**: https://fixture.email/docs ## About fixture.email is programmable email-testing infrastructure for software teams and the AI agents that build with them: create disposable mailboxes on demand, trigger the emails your application sends, and assert on everything that arrives — subjects, bodies, links, and headers. Humans and autonomous agents onboard the same way — a human signs up through hosted checkout and pastes a starter prompt, and from there an agent drives the whole email-testing flow over this API and the customer MCP, with no human in the loop. ## Base URL - **Current Environment**: https://send.fixture.email ## Authentication All `/api/*` endpoints require authentication via the `X-API-Key` header: ``` X-API-Key: {your-api-key} ``` **It must be that header, and it is enforced at the edge — not by the application.** A request to `/api/*` carrying no `X-API-Key` (or one shorter than 32 characters) is rejected before it reaches the API, and the reply is Cloudflare's generic HTML error page with status `403` — **not** a JSON error from this service. If you get back HTML titled "Attention Required!", you are almost certainly sending the wrong header: this API does **not** use `Authorization: Bearer`. Add `X-API-Key` and you will reach the application, which answers in JSON. That HTML `403` says nothing about your IP, your user agent, or bot detection, and retrying from a different network will not change it. Agents calling from datacenter IPs with non-browser clients are the expected audience here and are not blocked for being agents. Keys are org-scoped. Mint one in your browser at the keys portal (the `keys` host the MCP's `whoami` links to — sign in with your SSO, Google, GitHub, or email) with a required name and expiry, or via `POST /api/keys` (optional `expiresIn`). A key is shown only once at creation, is never recoverable, and stays valid until it expires or you revoke it (the `revoke_api_key` MCP tool or `DELETE /api/keys/{keyId}`). The following endpoints do NOT require authentication: - `GET /health` - Health check - `GET /llms.txt` - This documentation ### What is public, and what needs a key **This document is the public API contract.** `GET /llms.txt` (https://fixture.email/llms.txt) and `GET /health` are the only unauthenticated endpoints; everything under `/api/*` needs your org-scoped `X-API-Key`. That **includes the API description endpoints**: `GET /api/openapi.json` (the OpenAPI document) and `GET /api/docs` (its Swagger UI) are authenticated routes like any other. Being under `/api/*`, a keyless request to either is stopped at the edge and returns the HTML `403` described above — not a `401`, and not JSON. They are not a public discovery surface and are deliberately not advertised as one — our `/.well-known/api-catalog` and `Link: rel="service-doc"` header point at this document instead. So: build against the reference below, and read the OpenAPI document only once you hold a key and can send it. ### Check your credential before you do any work `GET https://send.fixture.email/api/usage` is the **documented preflight for a CI run or an agent session**. Call it once at the start; branch on the status code: | Status | Meaning | What to do | |---|---|---| | `200` | key valid, org entitled | proceed — and read `pressure.status`, below | | `401` | the key is wrong, revoked or expired | stop; mint a new key | | `402` | the org is unentitled | stop; the body names the checkout and portal URLs | | HTML `403` | no `X-API-Key` header reached the edge at all | add the header — see Authentication above | It is the cheapest gated call in the API: **read-only, no side effects, and it consumes no quota**, while still exercising the whole auth + entitlement path. One call names an unusable credential up front instead of letting it surface deep inside test setup after clone, install and provisioning. The `200` body also tells you what is about to run out, on **both** axes — a trial ends at whichever ceiling arrives first: - `pressure.status` — read this one field to decide whether to act. `ok` / `approaching_limit` / `exhausted`. - `pressure.sends` and `pressure.trialWindow` — then read these to see *which* ceiling is closing. - `trial.endsAt` with `trial.endsAtSource` — `confirmed` is safe to schedule against; `provisional` is our own checkout-time estimate and should be treated as an **upper** bound. A run already under way when `endsAt` passes is not cut off mid-execution: access continues for a short settle window after the end, so a preflight taken shortly past `endsAt` can still answer `200`. Do not read that as the trial having been extended — schedule against `endsAt`. - `organizationId` — which org this key actually resolved to. Two keys for two different orgs are otherwise indistinguishable to their holder. --- ## Quick Reference - All Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | GET | /health | Health check (no auth) | | GET | /llms.txt | This documentation (no auth) | | GET | /api/openapi.json | OpenAPI document (X-API-Key required; not public) | | GET | /api/docs | Swagger UI for that document (X-API-Key required; not public) | | POST | /api/mailboxes | Create mailbox | | GET | /api/mailboxes/{mailbox} | Get mailbox state (enabled, cleanup, domain, label) | | DELETE | /api/mailboxes/{mailbox} | Disable mailbox (does not schedule cleanup) | | POST | /api/mailboxes/{mailbox}/safe-cleanup | Schedule cleanup without disabling | | POST | /api/mailboxes/{mailbox}/enable | Enable mailbox | | POST | /api/mailboxes/{mailbox}/disable | Disable mailbox | | POST | /api/mailboxes/{mailbox}/cancel-cleanup | Cancel scheduled cleanup | | POST | /api/send | Send email (JSON payload) | | POST | /api/send/upload | Send email (raw .eml upload) | | POST | /api/attachments/upload | Upload large attachment | | GET | /api/status/{uuid} | Check email status | | GET | /api/messages | List messages with filters | | GET | /api/messages/{emailId}/content | Get parsed email body | | GET | /api/messages/{emailId}/raw | Get presigned URL for .eml download | | GET | /api/usage | Get outbound send usage vs per-org quota | | POST | /api/keys | Create an org-scoped API key | | GET | /api/keys | List API keys (never the secret) | | DELETE | /api/keys/{keyId} | Revoke an API key | | POST | /api/callbacks | Register a signed webhook callback | | GET | /api/callbacks | List callbacks (never the signing secret) | | DELETE | /api/callbacks/{id} | Delete a callback | --- ## Getting Started Two ways to drive this API: 1. **MCP (recommended for agents).** Point an MCP client at `https://mcp.fixture.email/mcp` for the tools (`create_mailbox`, `send_email`, `wait_for_message`, `wait_for_send_outcome`, `get_message`, `list_messages`, …). OAuth-capable clients follow the sign-in challenge and authenticate the single account owner without minting an API key or a seat. Local, automation, and other clients that accept explicit headers can instead send an org-scoped `X-API-Key`; use explicit API-key grants when access must be narrowed by category. **Over MCP you never write a polling loop:** `wait_for_message` returns the mail as soon as it arrives (and immediately if it already has), and `wait_for_send_outcome` returns a send's terminal outcome. The HTTP polling below is only for clients that are not using the MCP. 2. **HTTP API with an `X-API-Key`.** Direct HTTP remains key-authenticated. Mint a key in your browser at the keys portal (the `keys` host the MCP's `whoami` links to — sign in with your SSO, Google, GitHub, or email; required name + expiry, shown once), or via `POST /api/keys` once you already hold a key. ### Hello world — receive an email in 3 steps ```bash KEY=your-api-key # 1. Create a mailbox -> a unique inbox address MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes -H "X-API-Key: $KEY" | jq -r .mailbox) # 2. Send anything to test+$MAILBOX@fixture.email (from your app, or via the API) curl -s -X POST https://send.fixture.email/api/send -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ -d '{"to":"test+'"$MAILBOX"'@fixture.email","subject":"hello","text":"hi"}' # 3. Assert it arrived -> subject, body, links, headers curl -s "https://send.fixture.email/api/messages?mailbox=$MAILBOX&status=received" -H "X-API-Key: $KEY" ``` ### Connect your agent The customer MCP is a remote streamable-HTTP server. In an OAuth-capable client, add `https://mcp.fixture.email/mcp` without a static header and complete the account-owner sign-in. OAuth creates no API key and no additional seat. For a client that accepts explicit headers, swap `$KEY` below for a real org-scoped key: - **Claude Code**: `claude mcp add --transport http fixture-email https://mcp.fixture.email/mcp --header "X-API-Key: $KEY"` - **Cursor** — `.cursor/mcp.json`: ```json { "mcpServers": { "fixture-email": { "url": "https://mcp.fixture.email/mcp", "headers": { "X-API-Key": "$KEY" } } } } ``` - **Codex** — `~/.codex/config.toml`: ```toml [mcp_servers.fixture-email] url = "https://mcp.fixture.email/mcp" http_headers = { "X-API-Key" = "$KEY" } ``` ### Generate a reusable email-testing skill Once the flow round-trips, capture it as a reusable, portable skill in your repo so any teammate's agent repeats it — the file format is per-tool, the steps identical: - **Claude Code**: a `.claude/skills/email-testing/SKILL.md` (a name, a "Load when …" description, and the steps). - **Cursor**: a `.cursor/rules/email-testing.mdc` rule. - **Codex**: an `AGENTS.md` section. Record: create a mailbox, `send_email` an internal test message to its subaddress (off-quota, no app wiring), then a single `wait_for_message` (never a retry loop) and `get_message` to assert subject, body, links, and headers. Add `wait_for_send_outcome` when the test needs to prove the send itself reached `sent` / `failed`. Full receive/send/internal-routing detail is in the sections below. --- ## Paying for Access Every `/api/*` product route requires your organization to hold an **active subscription or a live trial**. A call from an org without one is rejected with **`402 Payment Required`** and a body naming why, and how to pay. The usual case by far is a **trial that reached its end date**: ```json { "error": "No active subscription or trial for this organization. Subscribe or update billing to continue.", "entitled": false, "reason": "trial_expired", "status": "trial_expired", "checkoutUrl": "https://fixture.email/api/billing/checkout", "billingPortalUrl": "https://fixture.email/api/billing/portal" } ``` - `entitled` — always `false` on a 402. It is derived from the same decision that refused the call, so it can never disagree with the status code. - `reason` — **the field to branch on.** A closed set naming which branch refused you: - `"trial_expired"` — the trial reached its end date and that end is confirmed — by far the most common lapse - `"no_active_subscription"` — the subscription is in a state that grants no access (`canceled`, `past_due`, `unpaid`, `incomplete`, `paused`, …), or the organization never had one - `status` — the org's *effective* billing state, not a stored label: an expired trial reports `"trial_expired"` (never `"trialing"`), and a lapsed subscription reports what it actually is (`"canceled"`, `"past_due"`, `"unpaid"`, `"incomplete"`, `"paused"`, …). Informational and open-ended — do not switch on it. - `checkoutUrl` — hosted checkout; open in a browser to subscribe with a card. - `billingPortalUrl` — the billing portal to manage or restore a subscription. A 402 never carries `"status": "active"` or `"status": "trialing"`; if you are matching on the old shape, match on the 402 status code or on `reason` instead. Hosted checkout collects your card; your trial starts (or the subscription activates) on completion. Billing routes are the only `/api/*` routes reachable while unentitled, so a lapsed org can always pay its way back in. After paying, retry your original call (or `GET https://send.fixture.email/api/usage`) — entitlement flips within seconds of the checkout being confirmed. The customer MCP server reports the same `entitled` / `reason` / `status` and the same `checkoutUrl` in its `payment_required` error. --- ## Core Concepts ### Email Address Format All emails use **subaddress-based mailbox routing** (RFC 5233): ``` {anything}+{mailbox}@fixture.email ``` - **mailbox**: 16-character lowercase alphanumeric ID (e.g., `x7k2m9pqr3s4t5u6`) - **{anything}**: Can be any valid local part - only the mailbox ID after `+` matters Examples: - `test+x7k2m9pqr3s4t5u6@fixture.email` - `signup+x7k2m9pqr3s4t5u6@fixture.email` - `newsletter+x7k2m9pqr3s4t5u6@fixture.email` All three addresses route to the same mailbox (`x7k2m9pqr3s4t5u6`). ### Mailboxes Mailboxes are isolated containers for email testing: - Create via `POST /api/mailboxes` (returns random 16-char ID) - Each mailbox has its own storage namespace - Only registered mailboxes can receive emails - Delete via `DELETE /api/mailboxes/{mailbox}` to clean up **Mailbox Deletion (Soft Delete with Grace Period):** - Mailboxes are **disabled** (enabled = 0) when deleted via API - Workflow timer (CLEANUP_SLEEP_DURATION) handles grace period - not database - During grace period: In-flight emails are rejected (mailbox is disabled) - After grace period: Mailbox and all data are permanently deleted - This prevents race conditions when tests delete mailboxes while emails are processing ### Email Statuses - `queued` - Email is queued for sending (initial state after POST /api/send) - `sent` - Email has been sent successfully to external recipient - `received` - Email has been received: - Inbound emails from external senders - **Internal routing**: Emails sent to `{anything}+{mailbox}@fixture.email` are routed internally and marked `received` in the recipient's mailbox - `failed` - Email processing failed: - Sending to non-existent mailbox on test domain - Workflow processing errors --- ## Receiving Emails (Inbound) To receive emails for testing (e.g., verification emails, notifications): 1. **Create a mailbox**: `POST /api/mailboxes` returns `{ "mailbox": "x7k2m9pqr3s4t5u6" }` 2. **Use the email address**: Send emails to `anything+x7k2m9pqr3s4t5u6@fixture.email` 3. **Poll for received emails**: `GET /api/messages?mailbox=x7k2m9pqr3s4t5u6&status=received` Emails sent to addresses with unregistered mailbox IDs are silently dropped. ### Receive Email Example ```bash # 1. Create a mailbox MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes \ -H "X-API-Key: $API_KEY" | jq -r '.mailbox') # 2. Use this address in your application: test+$MAILBOX@fixture.email # 3. Poll for received emails curl -s "https://send.fixture.email/api/messages?mailbox=$MAILBOX&status=received" \ -H "X-API-Key: $API_KEY" ``` --- ## Sending Emails (Outbound) ### Method 1: API Creation (POST /api/send) Build email from JSON payload: ```bash curl -X POST https://send.fixture.email/api/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "recipient@example.com", "subject": "Test Email", "text": "Plain text body", "html": "
HTML body
" }' ``` **Smart `from` Address Handling:** | You Provide | API Behavior | Result | |-------------|--------------|--------| | Nothing | Generate random local + create new mailbox | `r4nd0m+x7k2m9pqr3s4t5u6@fixture.email` | | Just local (e.g., `newsletter`) | Use local + create new mailbox | `newsletter+x7k2m9pqr3s4t5u6@fixture.email` | | Full address with valid mailbox | Validate mailbox exists, use as-is | `newsletter+x7k2m9pqr3s4t5u6@fixture.email` | | Full address with unknown mailbox | Return 400 error | - | ### Method 2: Raw .eml Upload (POST /api/send/upload) Upload a raw RFC 822 .eml file. Supports replaying external emails (e.g., saved from Gmail): ```bash # Upload your own .eml file curl -X POST https://send.fixture.email/api/send/upload \ -H "X-API-Key: $API_KEY" \ -F "file=@email.eml" # Replay external .eml with overridden From/To curl -X POST https://send.fixture.email/api/send/upload \ -H "X-API-Key: $API_KEY" \ -F "file=@saved-gmail.eml" \ -F "from=sender@fixture.email" \ -F "to=recipient@example.com" ``` **How External .eml Replay Works:** - The original `.eml` file is stored **unmodified** (preserves source for audit/reuse) - From/To overrides are stored as metadata - At send time, the workflow extracts body, subject, attachments from the original `.eml` - Rebuilds MIME message using the override From/To addresses - External From addresses (e.g., `someone@gmail.com`) are transformed to `{local}+{mailbox}@fixture.email` - Multiple recipients supported: `to=alice@example.com, bob@example.com` ### Large Attachments For attachments >= 1MB, upload first then reference by ID: ```bash # 1. Upload attachment ATTACHMENT=$(curl -s -X POST https://send.fixture.email/api/attachments/upload \ -H "X-API-Key: $API_KEY" \ -F "file=@large-file.pdf" \ -F "mailbox=$MAILBOX" | jq -r '.attachmentId') # 2. Send email with attachment reference curl -X POST https://send.fixture.email/api/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "sender+'$MAILBOX'@fixture.email", "to": "recipient@example.com", "subject": "Email with large attachment", "text": "See attached file", "attachments": [{ "name": "large-file.pdf", "attachmentId": "'$ATTACHMENT'", "contentType": "application/pdf" }] }' ``` --- ## Internal Routing (Test-to-Test) When you send an email **to** an address on the test domain (`{anything}+{mailbox}@fixture.email`), the email is routed internally: 1. Email is NOT sent externally 2. Status becomes `received` (not `sent`) 3. Email appears in the **recipient's** mailbox (not sender's) This enables end-to-end email testing without external delivery. ### Internal Routing Example ```bash # Create RECIPIENT mailbox (this is where email will be delivered) RECIPIENT_MAILBOX=$(curl -s -X POST https://send.fixture.email/api/mailboxes \ -H "X-API-Key: $API_KEY" | jq -r '.mailbox') # Send to the recipient's test domain address EMAIL_ID=$(curl -s -X POST https://send.fixture.email/api/send \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "test+'$RECIPIENT_MAILBOX'@fixture.email", "subject": "Internal Test", "text": "This is routed internally" }' | jq -r '.emailId') # Poll for RECEIVED status (not sent!) while true; do STATUS=$(curl -s "https://send.fixture.email/api/status/$EMAIL_ID" \ -H "X-API-Key: $API_KEY" | jq -r '.status') if [ "$STATUS" = "received" ]; then echo "Email received in recipient mailbox!" break fi if [ "$STATUS" = "failed" ]; then echo "Email failed - check if recipient mailbox exists" break fi sleep 3 done # Email is now in RECIPIENT's mailbox curl -s "https://send.fixture.email/api/messages?mailbox=$RECIPIENT_MAILBOX&status=received" \ -H "X-API-Key: $API_KEY" | jq '.messages[0]' ``` **Important**: If the recipient mailbox doesn't exist, the email status becomes `failed`. --- ## API Reference ### Health Check ``` GET /health ``` No authentication required. **Response:** ```json { "status": "ok", "service": "email-test-send", "environment": "production" } ``` --- ### Create Mailbox ``` POST /api/mailboxes ``` Creates a new mailbox with a random 16-character ID. No request body needed. **Response:** ```json { "mailbox": "x7k2m9pqr3s4t5u6" } ``` --- ### Get Mailbox ``` GET /api/mailboxes/{mailbox} ``` Get a mailbox's current state. **Response:** ```json { "mailbox": "x7k2m9pqr3s4t5u6", "domain": "fixture.email", "label": null, "enabled": true, "scheduledForCleanup": false, "createdAt": 1730961190634 } ``` - `label`: optional mailbox label (null if unset) - `scheduledForCleanup`: `true` once `POST /api/mailboxes/{mailbox}/safe-cleanup` has been called - `createdAt`: Unix timestamp in **milliseconds** **Status Codes:** - `200` - Mailbox found - `404` - Mailbox not found - `401` - Missing or invalid API key --- ### Delete Mailbox ``` DELETE /api/mailboxes/{mailbox} ``` Disables the mailbox. This does NOT schedule cleanup - cleanup must be scheduled separately via `/safe-cleanup` endpoint. **Behavior:** 1. Disables the mailbox (disabled mailboxes reject new emails immediately) 2. Takes effect immediately 3. Does NOT trigger cleanup 4. Does NOT schedule the mailbox for cleanup **Response:** ```json { "success": true } ``` **Status Codes:** - `200` - Mailbox disabled - `404` - Mailbox not found **Note**: If mailbox is already disabled, returns success (idempotent). To schedule cleanup, use `POST /api/mailboxes/{mailbox}/safe-cleanup`. --- ### Schedule Mailbox Cleanup ``` POST /api/mailboxes/{mailbox}/safe-cleanup ``` Schedules mailbox cleanup workflow without disabling the mailbox. Allows in-flight emails to complete before deletion. **Request Body**: Required when using `Content-Type: application/json`. Send at least `{}` (empty object). Sending no body with that header returns `400 Malformed JSON`. ```json {} ``` Or with optional duration: ```json { "duration": "30 minutes" } ``` - `duration` (optional): Duration override (e.g., "30 minutes", "1 hour", "2 days") - If not provided, uses `CLEANUP_SLEEP_DURATION` from environment - Duration format: `"{number} {unit}"` (e.g., "30 minutes", "1 hour") - Supported units: second, minute, hour, day, week **Behavior:** 1. Schedules the mailbox for cleanup after the grace period 2. Mailbox remains enabled during the grace period (allows in-flight emails) 3. After the grace period, all stored emails and metadata for the mailbox are permanently deleted 4. Can be cancelled via `POST /api/mailboxes/{mailbox}/cancel-cleanup` any time before the grace period ends **Response:** ```json { "success": true, "duration": "30 minutes" } ``` **Status Codes:** - `200` - Cleanup scheduled - `400` - Malformed JSON (e.g. `Content-Type: application/json` with no body—send at least `{}`), or invalid duration format - `404` - Mailbox not found --- ### Enable Mailbox ``` POST /api/mailboxes/{mailbox}/enable ``` Immediately enables mailbox (`enabled = 1`). This is an independent operation that does NOT affect cleanup scheduling. **Response:** ```json { "success": true, "warning": "Mailbox enabled, but this mailbox is scheduled for removal" } ``` The `warning` field is only present if the mailbox is scheduled for cleanup (`scheduledForCleanup = 1`). **Status Codes:** - `200` - Mailbox enabled - `404` - Mailbox not found (cleanup completed, mailbox removed) --- ### Disable Mailbox ``` POST /api/mailboxes/{mailbox}/disable ``` Immediately disables mailbox (`enabled = 0`). This is an independent operation that does NOT schedule cleanup. **Response:** ```json { "success": true } ``` **Status Codes:** - `200` - Mailbox disabled - `404` - Mailbox not found (cleanup completed, mailbox removed) --- ### Cancel Scheduled Cleanup ``` POST /api/mailboxes/{mailbox}/cancel-cleanup ``` Cancels scheduled mailbox cleanup by setting `scheduledForCleanup = 0`. Only works before cleanup workflow reaches deletion steps. **Response:** ```json { "success": true } ``` **Behavior:** - Cancels the scheduled cleanup for the mailbox - If cancelled before the grace period ends, no emails or metadata are deleted - Once the grace period has elapsed, cancellation may no longer take effect **Status Codes:** - `200` - Cleanup cancelled - `404` - Mailbox not found (cleanup completed, mailbox removed) --- ### Send Email (API Creation) ``` POST /api/send ``` **Request Body:** ```json { "from": "sender+mailbox@fixture.email", "to": "recipient@example.com", "subject": "Test Email", "text": "Plain text body", "html": "HTML body
", "cc": "cc@example.com", "bcc": ["bcc1@example.com", "bcc2@example.com"], "attachments": [ { "name": "small-file.txt", "content": "base64-encoded-content", "contentType": "text/plain" }, { "name": "large-file.pdf", "attachmentId": "uuid-from-upload", "contentType": "application/pdf" } ], "headers": { "X-E2E-Config": "value" } } ``` **Fields:** - `from` (optional): See smart from handling above - `to` (required): Recipient email address - `subject` (required): Email subject - `text` (optional): Plain text body - `html` (optional): HTML body - `cc` (optional): CC recipient(s); single email or array of emails - `bcc` (optional): BCC recipient(s); single email or array of emails - `attachments` (optional): Array of attachments - `name`: Filename - `contentType`: MIME type - `content`: Base64-encoded content (for files < 1MB) - `attachmentId`: ID from upload endpoint (for files >= 1MB) - `headers` (optional): `Record