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.
Creates an inbox. Every field is optional; an empty body gives you a random address.
| Field | Type | Description |
|---|---|---|
| local_part | string | The name before the @. Omit for a random one. |
| domain | string | Must be one returned by /v1/domains. Defaults to the first. |
| ttl_seconds | number | Lifetime, 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}'
Metadata for an inbox you hold the token for.
{
"address": "my-signup@audiooo.cc",
"created_at": 1785816262,
"expires_at": 1785823462,
"message_count": 2
}
Destroys the inbox and every message in it immediately. Returns 204.
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"
}
]
}
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.
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.
POST /v1/auth/login first and keeping the
cookie. An X-API-Key will not work here.
| Limit | Value |
|---|---|
| File lifetime | 24 hours, extendable 5 times |
| Per-file size | 100 MB |
| Per-account storage | 100 MB held at once |
| Refused types | Executables 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.
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 }
}
Multipart form upload.
| Field | Required | Description |
|---|---|---|
| file | yes | The file itself |
| name | no | Name to store it under. Defaults to the uploaded filename. |
| folder | no | Destination 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"
The file itself. Always served as
application/octet-stream with
Content-Disposition: attachment, whatever the file
actually is.
content_type from the listing if you need
to know the real type.
Deletes the file and its stored object. Returns 204.
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.
Adds 24 hours, up to five times per file. Returns 409
once the allowance is spent.
Folders
| Endpoint | Does |
|---|---|
| GET /v1/folders | Every folder you own, flat, each with its full path |
| POST /v1/folders | Creates one. Body: {"name", "parent"} |
| PATCH /v1/folders/:id | Renames or moves. Body: {"name", "parent"} |
| DELETE /v1/folders/:id | Deletes it and everything inside, files included |
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 },
});
}
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
| Endpoint | Returns |
|---|---|
| GET /v1/domains | Domains you can create addresses on |
| GET /health | {"status":"ok"} |
Limits
| Limit | Value |
|---|---|
| Inbox lifetime | 1 hour by default, 24 hours maximum |
| Extensions | 5 per address, 1 hour each |
| Grace after expiry | 30 minutes, only while extensions remain |
| Messages per inbox | 50, oldest dropped after that |
| Stored body size | 256 KB, truncated beyond |
| Inbox creation | 10 per minute per IP |
| Reads | 120 per minute per IP |
| Attachments | Not stored |
An API key raises the per-IP limits above.
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.
GET /v1/inboxes/:address reports
extensions_remaining and an expired flag,
which together tell you whether an address is live, rescuable, or gone.
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": "..."}.
| Status | Meaning |
|---|---|
| 400 | Malformed request, invalid name, or unknown domain |
| 401 | Missing bearer token |
| 404 | Not found — also returned for a wrong token, so addresses cannot be enumerated |
| 409 | That name is already taken |
| 429 | Rate limited |