Skip to content
machinemail

OTP codes and sign-up flows

Use ephemeral or catch-all inboxes to receive verification codes and magic links during sign-ups, extract them safely from the trust report, and let the inbox expire afterwards.

Agents often need an address for one job: sign up for a service, receive a one-time code or a magic link, and move on. MachineMail handles this with ephemeral inboxes that delete themselves, and with catch-all domains that create an address on first delivery.

Note

MachineMail does not extract codes for you. It classifies the message (trust.classification.category is otp for one-time codes and magic sign-in links) and gives you clean text and resolved links. The extraction itself is a regex in your code, shown below.

Pick an address

Ephemeral inbox

Create an inbox with ttl_seconds. It is deleted, with its messages and attachments, once expires_at passes. Every new inbound message pushes expires_at out to now plus ttl_seconds. Passing "ephemeral": true without a TTL uses 3600 seconds.

Terminal
curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 3600}'
signup.ts
const API = 'https://api.machinemail.dev/v0';
const headers = { Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`, 'Content-Type': 'application/json' };

const inbox = await fetch(`${API}/inboxes`, { method: 'POST', headers, body: JSON.stringify({ ttl_seconds: 3600 }) }).then((r) => r.json());
console.log(inbox.email, inbox.expires_at);
signup.py
import os, requests

API = "https://api.machinemail.dev/v0"
H = {"Authorization": f"Bearer {os.environ['MACHINEMAIL_API_KEY']}"}

inbox = requests.post(f"{API}/inboxes", headers=H, json={"ttl_seconds": 3600}).json()
print(inbox["email"], inbox["expires_at"])
Response (201)
{
  "inbox_id": "a2rz80xf1b49@machinemail.dev",
  "email": "a2rz80xf1b49@machinemail.dev",
  "status": "active",
  "ephemeral": true,
  "ttl_seconds": 3600,
  "expires_at": "2026-10-09T10:00:15.841Z",
  "created_by": "api",
  "created_at": "2026-10-09T09:00:15.841Z",
  "updated_at": "2026-10-09T09:00:15.841Z"
}

Without a username you get a random 12-character local part, which is hard to guess. Use it for sign-ups.

Catch-all address on your own domain

If you have a verified custom domain with catch_all: true, any address at that domain works without an API call. The inbox is created when the first message arrives, with created_by: "catch_all". Set catch_all_ttl_seconds on the domain to make those inboxes ephemeral.

This is useful when the agent must hand out an address before it can call the API, for example signup-7f3k@agents.acme.dev. See Use a custom domain.

Warning

Catch-all only works on your own verified domains. On the shared machinemail.dev domain, mail to an address that does not exist is rejected, so create the inbox first.

Trigger the email and wait

Note the time just before you submit the sign-up form, then wait for the first new message. Choose the waiting method that fits your runtime.

MethodNotes
MCP wait_for_messageBlocks up to 60 seconds and returns the message in agent-safe form. Pass after as the time you triggered the email. Quarantined, spam and unauthenticated mail does not wake it. See MCP.
SSEGET /v0/events/stream?inbox_id=...&event_types=message.received. See Realtime events.
PollingGET /v0/inboxes/{inbox_id}/messages every couple of seconds until a message appears.

A fresh ephemeral inbox has no other mail, so polling for the first inbound message is enough:

wait-for-code.ts
async function waitForMessage(inboxId: string, timeoutMs = 120_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const page = await fetch(`${API}/inboxes/${inboxId}/messages?limit=10`, { headers }).then((r) => r.json());
    const item = page.messages.find((m: any) => m.direction === 'inbound');
    if (item) return fetch(`${API}/inboxes/${inboxId}/messages/${item.message_id}`, { headers }).then((r) => r.json());
    await new Promise((r) => setTimeout(r, 2000));
  }
  throw new Error('No email arrived');
}

const msg = await waitForMessage(inbox.inbox_id);
wait_for_code.py
import time

def wait_for_message(inbox_id, timeout=120):
    deadline = time.time() + timeout
    while time.time() < deadline:
        page = requests.get(f"{API}/inboxes/{inbox_id}/messages", headers=H, params={"limit": 10}).json()
        item = next((m for m in page["messages"] if m["direction"] == "inbound"), None)
        if item:
            return requests.get(f"{API}/inboxes/{inbox_id}/messages/{item['message_id']}", headers=H).json()
        time.sleep(2)
    raise TimeoutError("No email arrived")

msg = wait_for_message(inbox["inbox_id"])

Check the message before using it

A code is only worth using if it came from the service you signed up with. Before extracting anything, check:

  • trust.level is not suspicious or quarantined. Quarantined mail is hidden from default lists anyway.
  • trust.sender.domain is the domain you expect, for example github.com.
  • trust.classification.category is otp. Other categories (transactional, notification) can still carry a confirmation link, so treat this as a hint, not a gate.

Extract a code

Read trust.content.text. It has hidden text, zero-width characters, quoted history and signatures removed, so a code hidden in invisible HTML cannot fool your regex. Fall back to extracted_text when there is no trust report.

extract-code.ts
function extractCode(msg: any): string | null {
  const text: string = msg.trust?.content?.text ?? msg.extracted_text ?? '';
  // A 4 to 8 digit code near a code-like word, else the first standalone 6-digit number.
  const near = /(?:code|passcode|otp|verification|pin)\D{0,40}?\b(\d{4,8})\b/i.exec(text);
  if (near) return near[1];
  return /\b(\d{6})\b/.exec(text)?.[1] ?? null;
}
extract_code.py
import re

def extract_code(msg):
    text = ((msg.get("trust") or {}).get("content") or {}).get("text") or msg.get("extracted_text") or ""
    near = re.search(r"(?:code|passcode|otp|verification|pin)\D{0,40}?\b(\d{4,8})\b", text, re.I)
    if near:
        return near.group(1)
    m = re.search(r"\b(\d{6})\b", text)
    return m.group(1) if m else None

Some services use alphanumeric codes such as K7Q-2PX. Adjust the pattern per service rather than widening it for all of them.

Do not parse links out of the raw HTML. Use trust.links. Every link in the message is listed with its target domain and flags:

trust.links (example)
[
  {
    "href": "https://github.com/login/device/confirm?token=abc123",
    "text": "Verify email address",
    "domain": "github.com",
    "display_mismatch": false,
    "shortener": false,
    "suspicious": false,
    "reasons": []
  }
]

Pick the link whose domain matches the service, and skip anything with display_mismatch (the visible text names a different site than the target), shortener or suspicious set.

extract-link.ts
function extractMagicLink(msg: any, expectedDomain: string): string | null {
  const links: any[] = msg.trust?.links ?? [];
  const ok = links.filter((l) => !l.display_mismatch && !l.shortener && !l.suspicious && (l.domain === expectedDomain || l.domain.endsWith(`.${expectedDomain}`)));
  return ok[0]?.href ?? null;
}

Clean up

Ephemeral inboxes delete themselves. A reaper runs every minute, removes inboxes past expires_at with all their messages, raw MIME and attachments, and emits inbox.expired with the inbox and the number of deleted messages.

If you are done early, delete the inbox yourself:

Terminal
curl -X DELETE https://api.machinemail.dev/v0/inboxes/a2rz80xf1b49@machinemail.dev \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"

If the account turns out to matter, keep the inbox by clearing its TTL:

Terminal
curl -X PATCH https://api.machinemail.dev/v0/inboxes/a2rz80xf1b49@machinemail.dev \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttl_seconds": null}'

Warning

Once an ephemeral inbox is deleted its address can be created again by anyone. Do not use ephemeral addresses for accounts you need to recover later; password resets would go to whoever holds the address next.