fabricatedemail HTTP API

What you need 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, created in the dashboard and shown once

The subscription list is 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.FABRICATEDEMAIL_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 created in the dashboard 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 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.

We recommend randomness

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.)

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:

GET /subscriptions

Lists the calling key's live subscriptions, oldest first. Expired subscriptions never appear.

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"
    }
  ]
}

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: every request counts against the key's rate limit.

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 codes and the links found in one message.

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

Authentication and every 404 are the single-message read's above.

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


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. Each tool call counts against the rate limit as one request, also inside a JSON-RPC batch. 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": {
    "fabricatedemail": {
      "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.


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. The count is approximate and kept per Cloudflare location. 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

Every reason a message never reaches this API, in the order worth checking, is on its own page: Why mail might not arrive.


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 in Why mail might not arrive: 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.