Quickstart · MachineMail Docs

1. Sign up and get an API key

This walks through the whole loop with curl, TypeScript (fetch) and Python (requests). It takes about five minutes.

  1. Go to machinemail.dev/signup, enter your email address and, optionally, an organization name. MachineMail emails you a sign-in link. There are no passwords.

Each mailbox can have one MachineMail account. Gmail addresses with the dots moved (or on googlemail.com) and + aliases such as you+test@example.com count as the same mailbox as you@example.com. If your mailbox already has an account, sign in with the address you first used.

  1. Open the link within 15 minutes. It works once. On your first sign-in MachineMail creates your organization, its default pod, its default inbox ({slug}@machinemail.dev) and a first API key.

  2. Right after you land, the dashboard shows your first key in a Welcome. Save your key dialog. This is the only time the full key is shown. Choose Copy, store the key, then choose I’ve saved it. If you lose it, revoke it and create another under API keys. You can create more keys there at any time. Create a key for an agent shows how.

Keep the key in an environment variable:

export MACHINEMAIL_API_KEY=mm_live_...

Warning An API key is shown only once and only its hash is stored. A key created at the top level can do everything in your organization. For agents, create narrower keys scoped to a pod or an inbox, with a permission list. See API keys.

Check that the key works:

curl https://api.machinemail.dev/v0/organizations \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"

2. Create an inbox

Your organization already has its default inbox. Create one per agent: an inbox is an email address. Pick a username, or leave it out and MachineMail generates one. Without a domain, the inbox is created on the shared machinemail.dev domain.

curl https://api.machinemail.dev/v0/inboxes \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username": "support-agent", "display_name": "Support agent"}'
const API = 'https://api.machinemail.dev/v0';
const headers = {
  Authorization: `Bearer ${process.env.MACHINEMAIL_API_KEY}`,
  'Content-Type': 'application/json',
};

const res = await fetch(`${API}/inboxes`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ username: 'support-agent', display_name: 'Support agent' }),
});
const inbox = await res.json();
console.log(inbox.inbox_id); // support-agent@machinemail.dev
import os
import requests

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

res = requests.post(
    f"{API}/inboxes",
    headers=headers,
    json={"username": "support-agent", "display_name": "Support agent"},
)
res.raise_for_status()
inbox = res.json()
print(inbox["inbox_id"])  # support-agent@machinemail.dev

Response (201)

{
  "inbox_id": "support-agent@machinemail.dev",
  "organization_id": "org_01m4fy8h140000efvagszh6rpx",
  "pod_id": "pod_01m4fy8h150000w4xty1ehfnqa",
  "email": "support-agent@machinemail.dev",
  "display_name": "Support agent",
  "status": "active",
  "ephemeral": false,
  "created_by": "api",
  "created_at": "2026-10-09T09:00:15.835Z",
  "updated_at": "2026-10-09T09:00:15.835Z"
}

The inbox_id is the address. Use it in every inbox path. Addresses on the shared domain are first come, first served: if support-agent is taken you get 409 conflict, so choose something more specific. GET /v0/inboxes/availability?username=support-agent tells you before you try.

Note Want an address that cleans up after itself? Add "ttl_seconds": 3600 and the inbox, with its messages, is deleted an hour after it was created or last received mail, whichever is later. See Inboxes.

3. Send an email

Send to an address you can read, so you can reply in the next step.

curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/send \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["you@example.com"],
    "subject": "Hello from an agent",
    "text": "Reply to this email and I will see it."
  }'
const inboxId = 'support-agent@machinemail.dev';

const res = await fetch(`${API}/inboxes/${inboxId}/messages/send`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    to: ['you@example.com'],
    subject: 'Hello from an agent',
    text: 'Reply to this email and I will see it.',
  }),
});
const sent = await res.json();
console.log(sent.message_id, sent.thread_id);
inbox_id = "support-agent@machinemail.dev"

res = requests.post(
    f"{API}/inboxes/{inbox_id}/messages/send",
    headers=headers,
    json={
        "to": ["you@example.com"],
        "subject": "Hello from an agent",
        "text": "Reply to this email and I will see it.",
    },
)
res.raise_for_status()
sent = res.json()
print(sent["message_id"], sent["thread_id"])

Response (201)

{
  "message_id": "msg_01m4fy8h340000kzb2kp66c161",
  "thread_id": "thd_01m4fy8h340001pp6c74x4688h",
  "timestamp": "2026-10-09T09:00:15.844Z"
}

The email goes out from Support agent <support-agent@machinemail.dev>. You can also send html, cc, bcc, reply_to, attachments and custom headers. See Sending.

4. Receive the reply

Reply to the email from your own mail client. MachineMail threads the reply onto the message you sent, computes a trust report and emits a message.received event. Pick one of three ways to find out about it.

Option A: poll

The simplest option. List messages newer than a timestamp, then fetch the full message, which includes the body and the trust report.

curl "https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages?labels=INBOX&after=2026-10-09T09:00:00Z" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"

curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fy8han0000y3ge3m73vw3w \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
const since = new Date().toISOString();

async function waitForReply(): Promise<any> {
  for (;;) {
    const url = `${API}/inboxes/${inboxId}/messages?labels=INBOX&after=${encodeURIComponent(since)}`;
    const { messages } = await (await fetch(url, { headers })).json();
    if (messages.length) {
      const id = messages[0].message_id;
      return (await fetch(`${API}/inboxes/${inboxId}/messages/${id}`, { headers })).json();
    }
    await new Promise((r) => setTimeout(r, 5000));
  }
}

const message = await waitForReply();
console.log(message.trust.level, message.trust.content.text);
import time
from datetime import datetime, timezone

since = datetime.now(timezone.utc).isoformat()

def wait_for_reply():
    while True:
        res = requests.get(
            f"{API}/inboxes/{inbox_id}/messages",
            headers=headers,
            params={"labels": "INBOX", "after": since},
        )
        messages = res.json()["messages"]
        if messages:
            msg_id = messages[0]["message_id"]
            return requests.get(f"{API}/inboxes/{inbox_id}/messages/{msg_id}", headers=headers).json()
        time.sleep(5)

message = wait_for_reply()
print(message["trust"]["level"], message["trust"]["content"]["text"])

The labels=INBOX filter skips your own sent messages, which carry SENT instead. after compares against the message timestamp, which for inbound mail comes from the sender’s Date header, so leave a minute of margin for clock skew. Lists are newest first and leave out spam and quarantined mail unless you ask for it with include_spam or include_quarantine.

Note Give your model trust.content.text, not text or html. It is the new part of the message with quoted history, signatures, hidden text and zero-width characters removed. The trust report explains every field.

When you have handled a message, mark it read by removing the UNREAD label:

curl -X PATCH https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fy8han0000y3ge3m73vw3w \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"remove_labels": ["UNREAD"]}'

Option B: webhook

Register an HTTPS endpoint and MachineMail POSTs every matching event to it, signed, with retries.

curl https://api.machinemail.dev/v0/webhooks \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/machinemail",
    "event_types": ["message.received"],
    "inbox_ids": ["support-agent@machinemail.dev"]
  }'
const res = await fetch(`${API}/webhooks`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    url: 'https://example.com/hooks/machinemail',
    event_types: ['message.received'],
    inbox_ids: [inboxId],
  }),
});
const webhook = await res.json();
// Store webhook.secret: you need it to verify signatures.
res = requests.post(
    f"{API}/webhooks",
    headers=headers,
    json={
        "url": "https://example.com/hooks/machinemail",
        "event_types": ["message.received"],
        "inbox_ids": [inbox_id],
    },
)
webhook = res.json()
# Store webhook["secret"]: you need it to verify signatures.

Response (201)

{
  "webhook_id": "whk_01m4fy8heb0000jbck9me3gqxb",
  "url": "https://example.com/hooks/machinemail",
  "event_types": ["message.received"],
  "inbox_ids": ["support-agent@machinemail.dev"],
  "secret": "whsec_hbANq3v94BDGbt1lDkzB8Y0tXJNVOSo9",
  "enabled": true,
  "status": "active",
  "created_at": "2026-10-09T09:00:16.203Z",
  "updated_at": "2026-10-09T09:00:16.203Z"
}

Every delivery is a JSON POST:

Webhook request

POST /hooks/machinemail HTTP/1.1
Content-Type: application/json
User-Agent: MachineMail-Webhooks/1.0
X-MachineMail-Signature: t=1791536561,v1=5f0c1e...
X-MachineMail-Event: message.received
X-MachineMail-Event-Id: evt_01m4fy8hba0000449dcj0gmkc5
X-MachineMail-Delivery-Id: dlv_01m4fy8hbb0000a7s1wq0c6m5e

{
  "type": "event",
  "event_id": "evt_01m4fy8hba0000449dcj0gmkc5",
  "event_type": "message.received",
  "timestamp": "2026-10-09T09:02:41.530Z",
  "message": { "message_id": "msg_01m4fy8han0000y3ge3m73vw3w", "trust": { "level": "verified" } },
  "thread": { "thread_id": "thd_01m4fy8h340001pp6c74x4688h", "message_count": 2 },
  "data": { "message": { "...": "..." }, "thread": { "...": "..." } }
}

The message and thread objects appear twice: at the top level, as AgentMail sends them, and inside data. Verify X-MachineMail-Signature before you trust the body and return a 2xx quickly. Verify webhook signatures has the code in TypeScript, Python and Go.

To try it without waiting for real mail, send a test event: POST /v0/webhooks/{webhook_id}/test.

Option C: Server-Sent Events

Open a long-lived stream and read events as they happen. Good for scripts and workers that cannot receive inbound HTTP.

curl -N "https://api.machinemail.dev/v0/events/stream?inbox_id=support-agent@machinemail.dev&event_types=message.received" \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY"
const url = `${API}/events/stream?inbox_id=${encodeURIComponent(inboxId)}&event_types=message.received`;
const res = await fetch(url, { headers: { Authorization: headers.Authorization } });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();

let buffer = '';
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;
  let end;
  while ((end = buffer.indexOf('\n\n')) !== -1) {
    const frame = buffer.slice(0, end);
    buffer = buffer.slice(end + 2);
    const data = frame.split('\n').find((l) => l.startsWith('data: '));
    if (!data) continue; // keep-alive comment
    const event = JSON.parse(data.slice(6));
    if (event.type === 'event') console.log(event.event_type, event.message?.subject);
  }
}
import json

with requests.get(
    f"{API}/events/stream",
    headers=headers,
    params={"inbox_id": inbox_id, "event_types": "message.received"},
    stream=True,
    timeout=(10, None),
) as res:
    for line in res.iter_lines(decode_unicode=True):
        if line and line.startswith("data: "):
            event = json.loads(line[6:])
            if event.get("type") == "event":
                print(event["event_type"], event["message"]["subject"])

5. Reply in the thread

Reply to the inbound message. MachineMail addresses it to the sender (or their Reply-To), sets Re: on the subject and adds In-Reply-To and References, so it lands in the same thread on both sides.

curl https://api.machinemail.dev/v0/inboxes/support-agent@machinemail.dev/messages/msg_01m4fy8han0000y3ge3m73vw3w/reply \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Got it, thanks."}'
await fetch(`${API}/inboxes/${inboxId}/messages/${message.message_id}/reply`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ text: 'Got it, thanks.' }),
});
requests.post(
    f"{API}/inboxes/{inbox_id}/messages/{message['message_id']}/reply",
    headers=headers,
    json={"text": "Got it, thanks."},
).raise_for_status()

Testing without a mail client

POST /inbound/raw delivers a raw RFC 5322 message straight into one of your inboxes, which is handy in tests and CI. It needs a key with the inbound_raw permission. Full-access keys do not include it: tick inbound_raw when you create the key in the dashboard (or create it with {"permissions": {"*": true, "inbound_raw": true}} from a key that already has it).

curl https://api.machinemail.dev/inbound/raw \
  -H "Authorization: Bearer $MACHINEMAIL_API_KEY" \
  -H "Content-Type: message/rfc822" \
  -H "X-Recipient: support-agent@machinemail.dev" \
  --data-binary $'From: Ada <ada@example.com>\r\nTo: support-agent@machinemail.dev\r\nSubject: Test\r\n\r\nYour verification code is 482913.\r\n'

Injected messages get received_via: "raw". Any Authentication-Results headers in them are ignored: authentication is always none, with the reason Injected via API (/inbound/raw); authentication results not verified, so injected mail is never verified or known.