fabricatedemail HTTP API

Disposable email addresses for automated tests. You register an address, your system under test sends mail to it, and you poll this API for the message — typically about a second after SMTP delivery. No inbound network access is needed on your side, so this works from CI runners without tunnels or relays.

This document is the whole contract. Everything a consuming project needs is here; nothing else in the repository is required reading.

What you need from the operator before you start:

Base URL https://api.fabricatedemail.com
Mail domain fabricatedemail.com — addresses are local-part@fabricatedemail.com, not @api.fabricatedemail.com
API key 64 hex characters, issued once by the operator (see Admin)

Stability policy. Additive changes ship at any time: new endpoints, new optional fields, new status codes on existing endpoints. Write clients that ignore fields they do not recognise and that branch on the status codes they know. A change that removes or renames a field, changes what an existing status means, or otherwise breaks a working client gets a new path prefix — /v2/ — and the prefix it replaces keeps working for six months.

Subscription and key lists are unpaginated (bounded by the caps below); a message list returns at most 50 messages per response — page with the after cursor (see Messages).


Quick start

const BASE = "https://api.fabricatedemail.com";
const KEY = process.env.TEMPMAIL_KEY!;
const h = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

// 1. A per-run random address on the mail domain: 8 random bytes = 64 bits.
//    (Not sliced from a UUID — its fixed version nibble would cut this to 60.)
const rand = Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
const address = `myapp-${rand}@fabricatedemail.com`;

// 2. Register it. 201 means the mailbox is live *now* — mail sent after this
//    response returns is guaranteed to be visible to the poll below.
const created = await fetch(`${BASE}/subscriptions`, {
  method: "POST",
  headers: h,
  body: JSON.stringify({ address, ttlSeconds: 900 }),
}).then((r) => r.json());

// 3. Trigger whatever sends the mail.
await signUp(address);

// 4. Long-poll for it: returns as soon as mail lands, or after 30 s empty.
const { messages } = await fetch(
  `${BASE}/subscriptions/${created.id}/messages?wait=30`,
  { headers: h },
).then((r) => r.json());

const code = /\b(\d{6})\b/.exec(messages[0].text ?? "")?.[1];

// 5. Optional — free the address early. It expires on its own TTL anyway.
await fetch(`${BASE}/subscriptions/${created.id}`, { method: "DELETE", headers: h });

Copy-paste recipes for the common assertions are in Assertion recipes.


Authentication

Every endpoint except GET /health requires:

Authorization: Bearer <api-key>

The scheme is matched case-insensitively. A missing header, a malformed credential, an unknown key, and a revoked key are all 401 {"error": "unauthorized"} and indistinguishable from outside.

A key sees only its own subscriptions and messages. Another key's subscription id is a 404 to you, not a 403; the one thing 403 means is that the key's account is suspended.

Keys are issued by the operator with the admin endpoints and are shown exactly once at creation — only a SHA-256 hash is stored, so a lost key cannot be recovered, only replaced. Store yours as a CI secret.


General contract

Status codes

Status When
200 Successful read
201 Subscription or key created
204 Deleted (no body)
400 Malformed address, a reserved address local part, ttlSeconds out of range or wrong type, non-integer after, negative or non-integer wait, body that is not a JSON object
401 Missing, malformed, unknown or revoked key
403 The key's account is suspended. Every key of the account sees it, on every endpoint
404 Unknown route; unknown, expired, deleted, replaced or foreign-owned subscription; absent message id
409 The address has a live subscription owned by another key
429 (address cap) The account is at its live-address cap, or at the technical bound. No Retry-After — waiting frees nothing
429 (rate limit) The key passed 600 requests in a minute. Carries Retry-After: 60
500 Server-side failure; logged by the service with the real cause. Retry

Addresses

You compose the address; the service does not generate it.

Randomness is mandatory

Use a per-run random address of at least 64 bits of entropy — for example 16 hex characters from 8 random bytes: myapp-4f2a9c81d3e6b7a0@fabricatedemail.com. (16 characters sliced from a UUID's hex are not 64 bits — the fixed version nibble leaves 60.)

Two reasons, both of which will bite you otherwise:

  1. Isolation. Registering an address you already hold replaces the old subscription and deletes its messages (see Re-registration). Two jobs sharing a deterministic address destroy each other's subscriptions and can read each other's mail.
  2. Security. The address is the only thing an outside sender needs in order to place content into your mailbox. Randomness is the boundary.

A single, permanently registered address for interactive or manual use is fine; anything that can run twice concurrently needs a fresh random address per run.


Subscriptions

A subscription is a claim on an address: "I will read mail sent here." It lives for its TTL, or until you delete it if it is permanent.

POST /subscriptions

Register an address.

POST /subscriptions
Authorization: Bearer <api-key>
Content-Type: application/json

{"address": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com", "ttlSeconds": 900}
201
{
  "id": "3f7c1c0e-1e5b-4b3f-9a2a-2f6f6d4b1c77",
  "address": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com",
  "createdAt": "2026-08-10T12:00:00.000Z",
  "expiresAt": "2026-08-10T12:15:00.000Z"
}

ttlSeconds:

Value Meaning
omitted Default TTL, 1 hour
integer 60 – 86400 That many seconds (60 s to 24 h)
null (explicit JSON null) Permanent — never expires, expiresAt is null, lives until DELETE
anything else — a non-integer, a numeric string, a boolean, an out-of-range integer 400

Failure modes:

Status Cause
400 Address outside the grammar or off the service domain; a reserved local part; address missing or not a string; ttlSeconds invalid; body not a JSON object
403 The account is suspended
409 Another key holds a live subscription for this address — including a permanent one, which stays live until its owner deletes it
429 (address cap) The account already holds as many live addresses as its tier allows, or 10 000, whichever is lower. No Retry-After: waiting frees nothing, and the fix is to delete an address or move up a tier
429 (rate limit) The key passed 600 requests in a minute. Retry-After: 60

An expired foreign subscription never causes a 409: the registration clears expired rows for the address as part of the write, so an address whose previous holder let it lapse is immediately reusable.

If two clients register the same free address at the same instant, exactly one wins; the other gets 409.

Re-registration replaces, and is terminal for the old id

Registering an address your own key already holds — live or expired — deletes the old subscription and its stored messages and returns 201 with a new id and a new expiry. Consequences you must design around:

Retrying a create

Retrying POST /subscriptions after a client-side timeout is safe, with one rare exception: if the original request commits after your retry did, the retry's subscription is the one that gets replaced, and its id turns 404.

The remedy is part of this contract: if a poll on a just-created subscription returns 404, re-create the subscription once and continue with the new id.

Permanent mailboxes

{"address": "...", "ttlSeconds": null} registers a permanent subscription:

Use one for a long-lived shared inbox (a manual QA address, a staging notification sink). Do not use one as the address for parallel test runs — the replacement semantics above still apply.

GET /subscriptions

Lists the calling key's live subscriptions, oldest first. Expired subscriptions never appear, whether or not the hourly cleanup has collected them yet.

200
{
  "subscriptions": [
    {
      "id": "3f7c1c0e-1e5b-4b3f-9a2a-2f6f6d4b1c77",
      "address": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com",
      "createdAt": "2026-08-10T12:00:00.000Z",
      "expiresAt": "2026-08-10T12:15:00.000Z"
    }
  ]
}

Unpaginated: the account's live-address cap bounds it, and the technical bound of 10 000 live addresses bounds it whatever the cap says. expiresAt is null for permanent subscriptions.

DELETE /subscriptions/{sid}

Ends a subscription early and deletes its stored messages in the same transaction.

Deleting is optional hygiene — a TTL'd subscription disappears on its own — but it frees the address for immediate reuse by another key.


Messages

GET /subscriptions/{sid}/messages?after=<id>&wait=<seconds>

Returns the stored messages for the subscription, oldest first.

200
{"messages": [ /* message objects, see below */ ]}
Parameter Type Default Behavior
after integer 0 Return only messages whose id is greater than this. Non-integer (including 1.5, 1e3, +1, an empty value): 400
wait integer seconds 0 Long-poll. 0 returns immediately. Values above 30 are clamped to 30, not rejected. Negative or non-integer: 400

The poll loop

let after = 0;
for (;;) {
  const r = await fetch(
    `${BASE}/subscriptions/${sid}/messages?after=${after}&wait=30`,
    { headers: { Authorization: `Bearer ${KEY}` } },
  );
  if (r.status === 404) break;            // expired, deleted or replaced — terminal
  const { messages } = await r.json();
  for (const m of messages) { handle(m); after = m.id; }
}

A well-behaved 30-second loop costs about two requests per minute. Do not poll in a tight loop with wait=0: the request quota is shared account-wide.

GET /subscriptions/{sid}/messages/{id}

Returns one message object, not wrapped in a list. {id} is the integer message id.

GET /subscriptions/{sid}/messages/{id}/extract

Returns the one-time codes and the links found in one message, so you do not have to write the regex yourself.

200
{
  "codes": ["482913"],
  "links": ["https://example.com/verify?t=8f14e45fceea167a"]
}

Authentication and every 404 are the single-message read's above: the same key, the same parent-liveness rule, the same 404 for a path segment that is not an integer.

The rules are heuristic and deliberately simple. They are written out here so you can predict them, and they can change — a change that finds more is additive under the stability policy. Assert on the code or the link you expected; never assert that a list is empty.

A message with neither answers {"codes": [], "links": []}: an empty result is 200, never 404. A headers-only row has no bodies to read and always answers two empty lists.

Message shape

{
  "id": 42,
  "messageId": "<abc@mail.example.com>",
  "from": "noreply@example.com",
  "to": "myapp-4f2a9c81d3e6b7a0@fabricatedemail.com",
  "subject": "Verify your account",
  "text": "Your code is 123456",
  "html": "<p>Your code is <b>123456</b></p>",
  "headers": [["from", "Example <noreply@example.com>"], ["subject", "Verify your account"]],
  "attachments": [{"filename": "invoice.pdf", "mimeType": "application/pdf", "size": 31337}],
  "truncated": false,
  "rawSize": 4096,
  "receivedAt": "2026-08-10T12:00:00.000Z"
}
Field Type Notes
id integer Service-assigned, monotonic. The after cursor and the single-message path parameter
messageId string | null The RFC 5322 Message-ID header, null when the sender did not set one. Display and debug data — never an API parameter
from string | null The parsed From: header address. On headers-only rows (see below) it is the SMTP envelope sender instead; the two can legitimately differ
to string Always the SMTP envelope recipient (RCPT TO) — the address that actually routed here, so a spoofed To: header cannot affect it. May differ from the to entry in headers
subject string | null
text string | null Plain-text body, capped at 256 KiB. null on headers-only rows and when the mail had no text part
html string | null HTML body, capped at 256 KiB. Same nullability rule
headers [name, value][] Ordered pairs, names lowercased. See the note below
attachments object[] {filename, mimeType, size}metadata only; content and raw MIME are not retrievable
truncated boolean true if anything was cut, or if the mail was stored headers-only
rawSize integer Size in bytes of the raw message as received
receivedAt string When the service received it

About headers. The list carries every header of the message, with names lowercased, in the platform's canonical (sorted) order, and repeated headers — Received, most often — joined into a single value separated by ", ". The one exception is set-cookie (crafted mail only): Fetch Headers iteration special-cases it, so repeats appear as separate pairs rather than one joined value. Look headers up by name; do not rely on their position, and expect a joined value where a header legitimately repeats:

const auth = message.headers.find(([n]) => n === "authentication-results")?.[1];

Nothing about expiry appears on a message: receivedAt plus the retention rule below is the whole contract.

Truncation and headers-only rows

Gate every body assertion on truncated. If you assert on text or html, check truncated === false first, or a large mail will fail your test with a confusing null.

Retention

Duplicates

Where a message came from

Anyone who learns an address can put mail in its mailbox. A test asserting origin must check from — and, where it matters, the stored authentication-results header — rather than mere presence.


MCP

A Model Context Protocol server on the same host, so an AI coding agent can register an address and read the mail itself instead of being told how to call this API.

POST /mcp

Streamable HTTP transport at https://api.fabricatedemail.com/mcp. It takes the same Authorization: Bearer <api-key> as every other endpoint, and the same key rules, suspension 403 and rate limit apply. Send Accept: application/json, text/event-stream and Content-Type: application/json, as the protocol requires. The server holds no session: each request is answered with a single JSON response.

Configuring a client is the endpoint and the key:

{
  "mcpServers": {
    "tempmail": {
      "type": "http",
      "url": "https://api.fabricatedemail.com/mcp",
      "headers": { "Authorization": "Bearer <api-key>" }
    }
  }
}

Tools, each a thin call to the endpoint beside it:

Tool Arguments Endpoint
create_address localPart (optional), ttlSeconds (optional, null for permanent) POST /subscriptions. Without localPart the tool generates a random one of 64 bits
list_addresses GET /subscriptions
delete_address subscriptionId DELETE /subscriptions/{sid}
wait_for_message subscriptionId, after (optional), waitSeconds (optional, at most 30, default 30) GET /subscriptions/{sid}/messages
get_message subscriptionId, messageId GET /subscriptions/{sid}/messages/{id}
extract subscriptionId, messageId GET /subscriptions/{sid}/messages/{id}/extract

A tool answers with its endpoint's JSON response as text. delete_address, whose endpoint has no body, answers {"deleted": true}. A status the endpoint refuses with — a 404 on a mailbox that is gone, a 409 on an address another key holds — comes back as a tool error carrying {"status": 404, "error": "Not found"}, so the agent is told exactly what a client would be told.

The tools do what the endpoints do and nothing more: the same quotas, the same statuses, and the same warnings about randomness and re-registration.


Health

GET /health

Unauthenticated liveness check; runs a trivial query through the database and exposes no data.


Admin (operator-only)

These endpoints require the operator's ADMIN_TOKEN, not a client key: Authorization: Bearer <admin-token>. A client key presented here is 401. Consumers never call them; they are documented so the operator has one place to look. The runbook that uses them is SETUP.md.

POST /admin/keys

Body {"name": "norna-ci"} — any non-empty string, stored trimmed.

201
{"id": "…", "name": "norna-ci", "key": "<64 hex chars>", "createdAt": "2026-08-10T12:00:00.000Z"}

key is returned exactly once and never again: only its SHA-256 is stored. A missing, blank or non-string name is 400.

GET /admin/keys

200
{"keys": [{"id": "…", "name": "norna-ci", "createdAt": "…", "revokedAt": null}]}

Every key ever minted, oldest first; revoked keys stay listed with their revokedAt. The key value itself is never returned.

DELETE /admin/keys/{id}

Revokes the key and deletes all of its subscriptions and messages in one transaction. 204; unknown id: 404. The key fails authentication from the next request on. Revoking an already-revoked key is 204 and keeps the original revocation time. A key created from the customer dashboard is the same kind of key and behaves exactly the same way when it is revoked, from either side.


Limits and caps

Limit Value What happens at the limit
Address local part 1–64 chars of [a-z0-9._+-] 400
Address randomness ≥ 64 bits, required for jobs that can overlap Not enforced; a collision destroys the other job's mailbox
ttlSeconds 60 s – 24 h, default 1 h, or null for permanent 400
Live addresses per account Free 1, Pro 25, Premium unlimited — counted across every key of the account, not per key 429 with no Retry-After. Approximate under concurrent registrations — it bounds runaway loops, it is not an exact quota. Re-registering an address you already hold still succeeds at the cap
Live addresses, technical bound 10 000 per account 429 with no Retry-After, whatever the tier allows. A bound on the service, not a tier feature
Requests per key 600 per minute 429 with Retry-After: 60. A 30-second poll loop costs about two requests a minute
Messages stored per account per month Free 60, Pro 25 000, Premium unlimited — the UTC calendar month Further mail is silently dropped, with no status anywhere in the API. Approximate under concurrent deliveries. An upgrade does not reset the count, and there is no reset other than the next month
Stored messages per subscription 1,000 Further mail is silently dropped — no error anywhere in the API. Only a server log line records it
wait 0–30 s Values above 30 are clamped to 30
text, html, serialized headers 256 KiB each Cut, truncated: true
Raw message size for body parsing 1 MiB Above it, stored headers-only with truncated: true
Inbound message size 25 MiB Rejected by the mail platform before the service sees it
Message retention 24 h from receipt, capped by the subscription's expiry Removed by the hourly cleanup
Poll latency ~1 s after SMTP delivery

Why mail might not arrive

The failure modes below are the ones that matter when a test goes red. Most of them are silent by design — dropping mail is deliberate, because rejecting it would bounce every misdirected test mail back at the sending provider's reputation.

  1. The sender is not authenticated. Cloudflare rejects inbound mail that fails both SPF and DKIM, mail failing the sender domain's DMARC policy, and mail from blocklisted IPs — before this service runs, non-configurably. Unauthenticated dev or staging SMTP vanishes without a trace here.

    Requirement: test mail must come from a properly configured sender (SES, SendGrid, Postmark, … with DNS set up). Verify this once, explicitly, before relying on any negative assertion.

  2. No live subscription for the address. The mail is dropped silently: no bounce, no rejection, nothing readable through the API. Expired, deleted, or replaced subscriptions all look like this. Check that you registered the exact address, lowercased, on the mail domain.
  3. The account is over its monthly message cap. Mail is dropped silently for the rest of the UTC calendar month, for every address of the account. Nothing in the API says so; the dashboard shows the month's count, and a warning is mailed at 80% and again at 100%.
  4. The account is suspended. Mail is dropped silently while it is, and every request answers 403. Reinstatement restores everything as it was, except the mail that arrived meanwhile, which is gone.
  5. The mailbox holds 1,000 messages. Further mail is dropped silently.
  6. The message is over 25 MiB. Rejected by the platform at SMTP receipt.
  7. The subscription was deleted or replaced while the mail was being stored. Dropped; the handle it targeted no longer exists.
  8. A database failure during storage. Storage is best-effort to the extent the database is available: such a message may be lost. Re-running the test regenerates it. The service claims no delivery guarantee stronger than this.
  9. Retention. Mail older than 24 hours is gone, and mail belonging to an expired subscription went with it.

When a negative assertion is load-bearing — or when a message you expected never showed up — ask the operator to check Workers Logs and the zone's Email Routing activity log. Platform-edge rejections (case 1) appear only in the latter. Log retention is three days, so investigate promptly.

The service cannot send mail. A test that needs to send mail into a system under test uses its own provider.


Assertion recipes

All snippets assume the BASE, KEY and h bindings from Quick start.

const api = (path: string, init?: RequestInit) =>
  fetch(`${BASE}${path}`, { ...init, headers: { ...h, ...init?.headers } });

Wait for a one-time code

The common case. Long-poll, don't sleep-and-check. The regex below is the one GET .../extract exists to save you writing; use it instead when its rules fit your mail.

async function waitForCode(sid: string, timeoutMs = 60_000): Promise<string> {
  const deadline = Date.now() + timeoutMs;
  let after = 0;
  while (Date.now() < deadline) {
    const r = await api(`/subscriptions/${sid}/messages?after=${after}&wait=30`);
    if (r.status === 404) throw new Error("subscription is gone — re-create it");
    const { messages } = await r.json();
    for (const m of messages) {
      after = m.id;
      if (m.truncated) continue;                      // body not usable
      const code = /\b(\d{6})\b/.exec(m.text ?? "")?.[1];
      if (code) return code;
    }
  }
  throw new Error("no code within the timeout");
}

Exactly one email

Sound only for mail carrying a Message-ID (all real providers) on a freshly registered random address. On a reused address, late mail from an earlier run makes this assertion meaningless.

await triggerSignup(address);
// Long-poll until the first message lands.
const { messages: arrived } = await api(`/subscriptions/${sid}/messages?wait=30`)
  .then((r) => r.json());
expect(arrived).not.toHaveLength(0);

await new Promise((r) => setTimeout(r, 10_000));      // quiet period
// Re-read the whole mailbox: redeliveries collapse, so a second row means a
// genuinely second message.
const { messages } = await api(`/subscriptions/${sid}/messages`).then((r) => r.json());
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe(arrived[0].id);

No email was sent

Poll for the expected quiet period and assert the list stays empty.

const { messages } = await api(`/subscriptions/${sid}/messages?wait=30`).then((r) => r.json());
expect(messages).toEqual([]);

Sound up to the loss modes above: a silent drop is indistinguishable from "nothing was sent". Before this assertion carries weight, prove once that a positive mail from the same sender does arrive — otherwise you are asserting that your SPF/DKIM setup is broken.

The mail came from the right place

const [m] = messages;
expect(m.from).toBe("noreply@your-product.example");
const authResults = m.headers.find(([n]) => n === "authentication-results")?.[1] ?? "";
expect(authResults).toMatch(/spf=pass/);

Parallel jobs

One random address per run, registered by that run, deleted (or simply left to expire) by that run:

const rand = Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
const address = `ci-${rand}@fabricatedemail.com`;
const { id: sid } = await api("/subscriptions", {
  method: "POST",
  body: JSON.stringify({ address, ttlSeconds: 900 }),
}).then((r) => r.json());
try {
  /* … the test … */
} finally {
  await api(`/subscriptions/${sid}`, { method: "DELETE" });
}

Never share an address between jobs that can overlap: the second registration deletes the first job's mailbox and its messages.

Handling the create-retry edge case

async function pollOrRecreate(sid: string, address: string) {
  const r = await api(`/subscriptions/${sid}/messages?wait=30`);
  if (r.status !== 404) return { sid, body: await r.json() };
  // The one documented case: a create retry whose original committed later.
  const recreated = await api("/subscriptions", {
    method: "POST",
    body: JSON.stringify({ address }),
  }).then((x) => x.json());
  return { sid: recreated.id, body: { messages: [] } };
}

Re-create once. A second 404 is a real expiry or deletion, not this race.


Checklist for a new consumer