Skip to content
machinemail

Quickstart

Sign up with a magic link, get an API key, create an inbox, send an email and receive replies by polling, webhook and Server-Sent Events.

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

1. Sign up and get an API key

  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.

  2. Open the link within 15 minutes. It works once. On your first sign-in MachineMail creates your organization, its default pod and a first API key.

  3. The dashboard shows the first key once, right after you land. Copy it. You can create more keys at any time under API keys.

Keep the key in an environment variable:

Terminal
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:

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

2. Create an inbox

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.

Terminal
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"}'
create-inbox.ts
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
create_inbox.py
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.

Terminal
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."
  }'
send.ts
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);
send.py
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.

Terminal
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"
poll.ts
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);
poll.py
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.

The full message looks like this (trimmed):

GET /v0/inboxes/{inbox_id}/messages/{message_id}
{
  "message_id": "msg_01m4fy8han0000y3ge3m73vw3w",
  "inbox_id": "support-agent@machinemail.dev",
  "thread_id": "thd_01m4fy8h340001pp6c74x4688h",
  "labels": ["INBOX", "UNREAD"],
  "from": "you@example.com",
  "to": ["support-agent@machinemail.dev"],
  "subject": "Re: Hello from an agent",
  "text": "Thanks, got it.\n\nOn Fri, Oct 9, 2026 at 9:00 AM Support agent wrote:\n> Reply to this email and I will see it.\n",
  "extracted_text": "Thanks, got it.",
  "direction": "inbound",
  "trust_level": "verified",
  "trust": {
    "level": "verified",
    "score": 0.97,
    "authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "aligned": true },
    "sender": {
      "address": "you@example.com",
      "domain": "example.com",
      "prior_correspondence": true,
      "on_allow_list": false,
      "on_block_list": false,
      "free_mail_provider": false
    },
    "content": {
      "text": "Thanks, got it.",
      "segments": [
        { "kind": "body", "text": "Thanks, got it." },
        { "kind": "quote", "text": "On Fri, Oct 9, 2026 at 9:00 AM Support agent wrote:\n> Reply to this email and I will see it." }
      ],
      "hidden_text_found": false,
      "zero_width_found": false,
      "tracking_pixels": 0
    },
    "links": [],
    "attachments": [],
    "classification": {
      "is_spam": 0.02,
      "is_phishing": 0.02,
      "contains_instructions_to_agent": 0.02,
      "requests_credentials_or_payment": 0.02,
      "urgency": 0.03,
      "category": "conversation",
      "language": "en",
      "backend": "heuristic"
    },
    "reasons": ["Verified: DMARC passed and aligned, and you have corresponded with this sender before"],
    "computed_at": "2026-10-09T09:02:41.512Z"
  }
}

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:

Terminal
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.

Terminal
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"]
  }'
webhook.ts
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.
webhook.py
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"
}

Each 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.

Terminal
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"
stream.ts
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);
  }
}
stream.py
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"])
Stream output
event: ready
data: {"type":"subscribed"}

id: evt_01m4fy8hba0000449dcj0gmkc5
event: message.received
data: {"type":"event","event_id":"evt_01m4fy8hba0000449dcj0gmkc5","event_type":"message.received","message":{...},"thread":{...},"data":{...}}

: ping

The stream needs a key with the metrics_read permission (unrestricted keys have it). It sends a : ping comment every 15 seconds when idle. Reconnect with the Last-Event-ID header to catch up on what you missed. Realtime events covers reconnection and the WebSocket alternative.

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.

Terminal
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."}'
reply.ts
await fetch(`${API}/inboxes/${inboxId}/messages/${message.message_id}/reply`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ text: 'Got it, thanks.' }),
});
reply.py
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). Keep it to test and CI keys.

Terminal
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.

Next