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
- Errors. Every error response has body
{"error": "<message>"}. The message is human-facing and may change; branch on the status code, never on the string. - Check order. Every request is decided in this order: authentication
(
401) → account state (403) → rate limit (429) → request validation (400) → existence/ownership (404) → cross-key conflict (409) → address cap (429). The first three are the account's and run on every key route; the rest are the route's own. A request that is wrong in two ways gets the earlier status: an unauthenticated request with a malformed body is401, and?after=abcon a subscription that does not exist is400. - Unknown routes and methods are
404 {"error": "Not found"}, including after successful authentication. - Timestamps — every timestamp the API returns is exactly the format of
JavaScript
Date.prototype.toISOString(): millisecond precision, trailingZ, e.g.2026-08-10T12:00:00.000Z. - Request bodies must be JSON objects. A malformed body, a JSON array, or
a bare JSON string is
400. Unknown fields are ignored. - Consistency. Register first, then trigger the mail. A
201fromPOST /subscriptionsmeans the mailbox is committed and visible to the mail handler, and a stored message is visible to your very next poll — there is no replication lag to design around.
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.
- Grammar.
local-part@fabricatedemail.com, where the local part is 1–64 characters of[a-z0-9._+-]. Exactly one@. The domain must be the service mail domain exactly. Subdomains are rejected. - Case. Addresses are compared and stored lowercased.
Foo@fabricatedemail.comis registered and returned asfoo@fabricatedemail.com, and mail addressed to either reaches the same mailbox. - Reserved local parts. These are the service's own and cannot be
registered:
abuse,postmaster,security,support,admin,noreply,no-reply,hostmaster,webmaster,info,billing,legal,dmarc. Mail to the ones that must receive is forwarded to a real inbox. Asking for one is400 {"error": "address local part is reserved"}. - Anything else is
400: a missing@, spaces, characters outside the grammar, an empty or 65-character local part, another domain.
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:
- A
404on a subscription id you were using means expired, deleted, or replaced. These are indistinguishable, and the handle is dead for good. Get a new one by registering again. - Overlapping jobs on the same deterministic address destroy each other's mailboxes. Use per-run random addresses.
- A reused deterministic address can receive late mail triggered by an earlier run, so "exactly one email" assertions on reused addresses are unsound.
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:
expiresAtisnullin this and every later response for it.- It never expires and is never garbage-collected.
DELETEis the only thing that ends it. - It still counts towards the account's live-address cap, for as long as it exists.
- Another key registering the same address still gets
409. - Its messages still expire on the normal 24-hour retention.
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.
204on success, with no body.404for an unknown, expired, already-deleted, or foreign-owned id.- This is the only way a permanent subscription ever ends.
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 |
- At most 50 messages per response, oldest first. Advance
afterto the lastidyou received and request again. afterand the single-message path parameter are always the integeridfield — never the RFC 5322messageId. Ids start at 1, so an omittedafterreturns everything currently stored.idis monotonically increasing but not contiguous within a mailbox, so your mailbox may see ids 7, 12, 13. Never compute the next cursor.- With
wait, the service re-checks about once per second and returns as soon as at least one message matches. On timeout it returns200 {"messages": []}; this is a normal, non-error result. 404for an unknown, expired, deleted, replaced or foreign-ownedsid. Parameter validation happens first, so a badafteron a nonexistent subscription is400.
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.
404if the message does not exist, belongs to a different subscription, or the path segment is not an integer.404if the parent subscription is missing or expired.- A message can therefore disappear from a live mailbox once its 24-hour retention has passed and the hourly cleanup has collected it.
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
text,html, and the serializedheadersare each capped at 256 KiB. If anything was cut,truncatedistrue(textandhtmlare cut at a character boundary;headersloses whole pairs from the end).- A message whose raw size exceeds 1 MiB is stored headers-only: the body
is never parsed, so
textandhtmlarenull,attachmentsis[],fromis the envelope sender, andtruncatedistrue. This is a CPU guard, not a failure — the message is stored and readable.
Gate every body assertion on
truncated. If you assert ontextorhtml, checktruncated === falsefirst, or a large mail will fail your test with a confusingnull.
Retention
- A stored message lives 24 hours from receipt, capped by its subscription's own expiry when the subscription has one. A subscription that expires in 15 minutes takes its mail with it; a permanent mailbox keeps each message for its full 24 hours.
- Expired rows are removed by an hourly cleanup. A message past its retention may therefore still be readable, normally for up to an hour, and longer if a cleanup run fails.
Duplicates
- Mail carrying an RFC 5322
Message-ID— which is all mail from real providers — is collapsed server-side across SMTP redeliveries into one stored message. "Exactly one email arrived" assertions are sound for such mail. - Mail without a
Message-IDis deduplicated best-effort on a fallback key over the sender-originatedFrom,To,SubjectandDateheaders.
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.
200 {"ok": true}500if the database binding is unavailable. Treat the status as the contract; the body is a generic error object.
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.