The API

Create disposable addresses and read their mail over HTTP. No account is required to use it — an API key only raises your rate limit.

https://audiooo.cc

Quickstart

Create an address, then poll it for mail. Two calls, no setup.

# 1. take an address — the token comes back once, keep it
curl -X POST https://audiooo.cc/v1/inboxes

{
  "address": "mira.holloway@audiooo.cc",
  "token": "H8sQ...",
  "created_at": 1785816262,
  "expires_at": 1785819862
}

# 2. read the mail
curl https://audiooo.cc/v1/inboxes/mira.holloway@audiooo.cc/messages \
  -H "Authorization: Bearer H8sQ..."

Authentication

Two independent credentials, used for different things.

The inbox token is returned once when you create an inbox and is required to read or delete its mail. Only a hash of it is stored, so a lost token cannot be recovered — create a new inbox instead. Send it as Authorization: Bearer <token>.

An API key is optional and identifies you for rate limiting only. It grants no access to anyone else's mail. Send it as X-API-Key: wt_.... Create one on the account page.

Anyone holding an inbox token can read that inbox. Treat it like a password.
POST /v1/inboxes

Creates an inbox. Every field is optional; an empty body gives you a random address.

FieldTypeDescription
local_partstringThe name before the @. Omit for a random one.
domainstringMust be one returned by /v1/domains. Defaults to the first.
ttl_secondsnumberLifetime, 60 to 86400. Defaults to 3600.
curl -X POST https://audiooo.cc/v1/inboxes \
  -H "Content-Type: application/json" \
  -d '{"local_part": "my-signup", "ttl_seconds": 7200}'
201 created 400 invalid or reserved name 409 name taken 429 rate limited
GET /v1/inboxes/:address

Metadata for an inbox you hold the token for.

{
  "address": "my-signup@audiooo.cc",
  "created_at": 1785816262,
  "expires_at": 1785823462,
  "message_count": 2
}
DELETE /v1/inboxes/:address

Destroys the inbox and every message in it immediately. Returns 204.

GET /v1/inboxes/:address/messages

Lists messages, newest first. Bodies are omitted — fetch a single message for those.

{
  "address": "my-signup@audiooo.cc",
  "expires_at": 1785823462,
  "messages": [
    {
      "id": "0f2c8e1a-...",
      "from_addr": "noreply@github.com",
      "from_name": "GitHub",
      "subject": "Your verification code",
      "size": 4821,
      "truncated": 0,
      "seen": 0,
      "received_at": 1785816302,
      "delivered_to": "mira.holloway@audiooo.cc"
    }
  ]
}
GET /v1/inboxes/:address/messages/:id

The full message, adding text_body and html_body. Reading marks it seen.

html_body is attacker-controlled. Render it in a sandboxed iframe or sanitise it — never inject it into your page directly.
DELETE /v1/inboxes/:address/messages/:id

Deletes one message. Returns 204, or 404 if it isn't there.

Files

Per-account file storage. Files are private to the account that uploaded them — there are no public URLs and no share links, so a file is reachable only by its owner.

These endpoints authenticate with a session cookie, not a bearer token or API key. They are built for the dashboard at /files, so calling them from a script means signing in with POST /v1/auth/login first and keeping the cookie. An X-API-Key will not work here.
LimitValue
File lifetime24 hours, extendable 5 times
Per-file size100 MB
Per-account storage100 MB held at once
Refused typesExecutables and scripts, detected by content

Type screening reads the file's leading bytes rather than its name, so an executable renamed to holiday.png is still refused with 415.

GET /v1/files

Contents of one folder. Omit folder for the top level. Returns the breadcrumb trail, subfolders, files, and your usage.

curl "https://audiooo.cc/v1/files?folder=FOLDER_ID" -b cookies.txt

{
  "path": [{ "id": "fad1…", "name": "trip" }],
  "folders": [],
  "files": [
    {
      "id": "613f…",
      "name": "holiday.png",
      "size": 31,
      "content_type": "image/png",
      "created_at": 1785942467,
      "expires_at": 1786028867,
      "extensions_remaining": 5,
      "type_mismatch": false
    }
  ],
  "usage": { "used": 31, "quota": 104857600 }
}
POST /v1/files

Multipart form upload.

FieldRequiredDescription
fileyesThe file itself
namenoName to store it under. Defaults to the uploaded filename.
foldernoDestination folder id. Omit for the top level.
curl -X POST https://audiooo.cc/v1/files   -b cookies.txt   -F "file=@holiday.png"   -F "name=holiday.png"
201 stored 409 name taken 413 too large 415 refused type 507 quota or storage full
GET /v1/files/:id/content

The file itself. Always served as application/octet-stream with Content-Disposition: attachment, whatever the file actually is.

That is deliberate. Serving uploads inline on this origin would let an uploaded HTML or SVG file run as a script against the owner's own session. Read content_type from the listing if you need to know the real type.
DELETE /v1/files/:id

Deletes the file and its stored object. Returns 204.

POST /v1/files/:id

Renames or moves, sent as PATCH. Both fields are optional; send either or both.

curl -X PATCH https://audiooo.cc/v1/files/FILE_ID   -b cookies.txt -H "Content-Type: application/json"   -d '{"name": "renamed.png", "folder": "FOLDER_ID"}'

Pass "folder": null to move something back to the top level. Renaming and moving only change metadata — the stored object is keyed by an internal id and never moves, so neither costs anything.

POST /v1/files/:id/extend

Adds 24 hours, up to five times per file. Returns 409 once the allowance is spent.

Folders

EndpointDoes
GET /v1/foldersEvery folder you own, flat, each with its full path
POST /v1/foldersCreates one. Body: {"name", "parent"}
PATCH /v1/folders/:idRenames or moves. Body: {"name", "parent"}
DELETE /v1/folders/:idDeletes it and everything inside, files included
Deleting a folder deletes every file beneath it. There is no undo and no recycle bin.

A folder cannot be moved into itself or into one of its own descendants — that would detach the whole subtree from the root and leave it unreachable. Attempting it returns 400.

TypeScript

Every shape the API returns. Copy this into your project — there is no package to install, and no build step involved.

// audiooo.d.ts

export interface Inbox {
  address: string;
  /** Returned once, at creation. Only its hash is stored. */
  token: string;
  created_at: number;
  expires_at: number;
  /** True when created while signed in, so it appears in your saved list. */
  saved: boolean;
  extensions_remaining: number;
}

export interface InboxDetails {
  address: string;
  created_at: number;
  expires_at: number;
  message_count: number;
  extensions_used: number;
  extensions_remaining: number;
  extension_seconds: number;
  /** Stopped receiving, but still readable and extendable for a grace hour. */
  expired: boolean;
}

export interface MessageSummary {
  id: string;
  /** SMTP envelope sender. Not the From: header, which is spoofable. */
  from_addr: string;
  from_name: string | null;
  subject: string | null;
  size: number;
  /** 1 when the body was cut at the 256 KB cap. */
  truncated: 0 | 1;
  seen: 0 | 1;
  received_at: number;
  /** The exact address used, which may be a +alias of the mailbox. */
  delivered_to: string | null;
}

export interface Message extends MessageSummary {
  text_body: string | null;
  /** Attacker-controlled. Sanitise it or render it in a sandboxed iframe. */
  html_body: string | null;
}

export interface FileEntry {
  id: string;
  name: string;
  size: number;
  content_type: string;
  created_at: number;
  expires_at: number;
  extensions_remaining: number;
  /** The extension disagrees with the detected content type. */
  type_mismatch: boolean;
}

export interface Folder {
  id: string;
  name: string;
  created_at: number;
}

export interface FolderListing {
  path: Folder[];
  folders: Folder[];
  files: FileEntry[];
  usage: { used: number; quota: number };
}

export interface ApiError {
  error: string;
}

Every timestamp is Unix seconds, not milliseconds — multiply by 1000 before handing one to new Date().

Typed client

Enough to be useful without being a framework. Runs unchanged in Node 18+, Bun, Deno, Workers, and browsers.

const BASE = "https://audiooo.cc";

class AudioooError extends Error {
  constructor(message: string, readonly status: number) {
    super(message);
    this.name = "AudioooError";
  }
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const response = await fetch(BASE + path, init);

  if (response.status === 204) return undefined as T;

  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    throw new AudioooError(
      (data as ApiError).error ?? response.statusText,
      response.status,
    );
  }
  return data as T;
}

/** Creates an inbox. Keep the token: it is shown once and never again. */
export function createInbox(options: {
  local_part?: string;
  domain?: string;
  ttl_seconds?: number;
} = {}): Promise<Inbox> {
  return request<Inbox>("/v1/inboxes", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(options),
  });
}

export function listMessages(inbox: Inbox): Promise<{ messages: MessageSummary[] }> {
  return request(
    `/v1/inboxes/${encodeURIComponent(inbox.address)}/messages`,
    { headers: { Authorization: `Bearer ${inbox.token}` } },
  );
}

export function readMessage(inbox: Inbox, id: string): Promise<Message> {
  return request<Message>(
    `/v1/inboxes/${encodeURIComponent(inbox.address)}/messages/${id}`,
    { headers: { Authorization: `Bearer ${inbox.token}` } },
  );
}

/**
 * Waits for the first message to arrive.
 *
 * Polling is the only option: there are no webhooks. Four seconds sits well
 * inside the 120-reads-per-minute limit.
 */
export async function waitForMessage(
  inbox: Inbox,
  { timeoutMs = 120_000, intervalMs = 4_000 } = {},
): Promise<Message> {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const { messages } = await listMessages(inbox);
    if (messages.length > 0) return readMessage(inbox, messages[0].id);
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }
  throw new Error(`No mail arrived at ${inbox.address} within ${timeoutMs}ms`);
}

Using it

const inbox = await createInbox({ ttl_seconds: 1800 });
console.log("Sign up with:", inbox.address);

const message = await waitForMessage(inbox);
const code = message.text_body?.match(/\b\d{6}\b/)?.[0];
console.log("Verification code:", code);

Extracting a link

/** Verification emails are often HTML-only, so check both bodies. */
function firstLink(message: Message): string | null {
  const source = `${message.text_body ?? ""} ${message.html_body ?? ""}`;
  return source.match(/https?:\/\/[^\s"<>]+/)?.[0] ?? null;
}

Files

File endpoints use a session cookie rather than a token, so sign in first and reuse the cookie. In Node, pass credentials: "include" or manage the Set-Cookie header yourself.

export async function signIn(username: string, password: string): Promise<string> {
  const response = await fetch(BASE + "/v1/auth/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!response.ok) throw new AudioooError("Sign-in failed", response.status);

  // Keep this and send it as Cookie: on subsequent file requests.
  return (response.headers.get("set-cookie") ?? "").split(";")[0];
}

export async function upload(
  cookie: string,
  file: File,
  folder?: string,
): Promise<FileEntry> {
  const form = new FormData();
  form.append("file", file);
  form.append("name", file.name);
  if (folder) form.append("folder", folder);

  return request<FileEntry>("/v1/files", {
    method: "POST",
    headers: { Cookie: cookie },
    body: form,   // never set Content-Type here; the boundary is generated
  });
}

export function listFiles(cookie: string, folder?: string): Promise<FolderListing> {
  const query = folder ? `?folder=${encodeURIComponent(folder)}` : "";
  return request<FolderListing>(`/v1/files${query}`, {
    headers: { Cookie: cookie },
  });
}
Setting Content-Type by hand on a FormData upload is the usual mistake — it overwrites the multipart boundary and the request fails to parse. Leave it off.

Service endpoints

EndpointReturns
GET /v1/domainsDomains you can create addresses on
GET /health{"status":"ok"}

Limits

LimitValue
Inbox lifetime1 hour by default, 24 hours maximum
Extensions5 per address, 1 hour each
Grace after expiry30 minutes, only while extensions remain
Messages per inbox50, oldest dropped after that
Stored body size256 KB, truncated beyond
Inbox creation10 per minute per IP
Reads120 per minute per IP
AttachmentsNot stored

An API key raises the per-IP limits above.

POST /v1/inboxes/:address/extend

Pushes the expiry out by one hour. Each address may be extended five times, so the longest an address can live is its original lifetime plus five hours. The cap is what keeps the service disposable.

{
  "address": "my-signup@audiooo.cc",
  "expires_at": 1785827062,
  "extensions_used": 1,
  "extensions_remaining": 4,
  "expired": false
}

An address that runs out of time while it still has extensions left is not deleted straight away. For 30 minutes afterwards it stops receiving mail but can still be read and extended, so stepping away briefly does not cost you the inbox.

An address with no extensions left gets no window at all — it is gone the moment its time runs out, and the name becomes available to anyone immediately, without waiting for the hourly sweep.

A reclaimed address carries nothing over. The previous holder's messages are deleted as part of handing the name on, and their token stops working — so nobody ever opens an address and finds someone else's mail.

GET /v1/inboxes/:address reports extensions_remaining and an expired flag, which together tell you whether an address is live, rescuable, or gone.

200 extended 409 no extensions left 404 unknown address or wrong token

Aliases

Any address accepts aliases with a +, exactly as Gmail does. If you hold hello@audiooo.cc, then hello+netflix@audiooo.cc and hello+forum@audiooo.cc both land in the same inbox with no setup.

Each message carries a delivered_to field with the exact address the sender used, so you can tell which alias a message arrived through — and therefore which one leaked.

{
  "from_addr": "noreply@example.com",
  "subject": "Welcome",
  "delivered_to": "hello+netflix@audiooo.cc"
}

Aliases cannot be registered on their own. Creating an inbox with a + in the name returns 400, because the alias space belongs to whoever holds the base address — otherwise a stranger could claim hello+netflix@ and intercept your mail.

A reserved name stays reserved through its aliases: admin+anything@ is refused just like admin@.

Address names

A name is 1 to 64 characters of a-z, 0-9, ., -, _ and +, and cannot start or end with a dot. Names are lowercased.

Names may not contain + — see Aliases above. Eleven names are refused: admin, administrator, webmaster, hostmaster, postmaster, abuse, security, root, sysadmin, ssladmin and ssl-admin. A certificate authority will issue a TLS certificate for a domain to whoever receives mail at the first five, so they cannot be handed out.

Note that signup forms elsewhere often validate email more strictly than this API does, so an address accepted here may still be rejected there.

Errors

Every error is JSON of the shape {"error": "..."}.

StatusMeaning
400Malformed request, invalid name, or unknown domain
401Missing bearer token
404Not found — also returned for a wrong token, so addresses cannot be enumerated
409That name is already taken
429Rate limited