quickstart.md
title: "Quickstart" description: "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." canonical: "https://machinemail.dev/docs/getting-started/quickstart"
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.
Source: https://machinemail.dev/docs/getting-started/quickstart
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
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.
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.
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
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"}'
TypeScript
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
Python
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
{
"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": 3600and 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
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."
}'
TypeScript
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);
Python
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"])
{
"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
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"
TypeScript
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);
Python
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):
{
"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 written to this address 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:
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
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"]
}'
TypeScript
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.
Python
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.
{
"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:
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
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"
TypeScript
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);
}
}
Python
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"])
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.
curl
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."}'
TypeScript
await fetch(`${API}/inboxes/${inboxId}/messages/${message.message_id}/reply`, {
method: 'POST',
headers,
body: JSON.stringify({ text: 'Got it, thanks.' }),
});
Python
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.
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
- Give an agent an inbox: The full receive, check, act and reply loop.
- OTP codes and sign-ups: Throwaway inboxes for verification codes and magic links.
- Using trust in agents: Rules for what an agent should and should not act on.
- Connect over MCP: Use the same key from Claude Code, Cursor and other MCP clients.