Everything is JSON over HTTPS at https://maildesk.email.
Machine-readable spec: GET /v0/openapi.json (no key needed).
The same operations are exposed as native MCP tools โ see MCP.
Pass your key as a bearer token on every request:
Authorization: Bearer ap_your_key_here
Keys carry scopes โ read, write, admin. Self-serve signup keys get
read + write. Check what a key can do with GET /v0/me.
No key yet? POST /v0/signup {"email": "you@example.com"}, then trade the emailed
6-digit code via POST /v0/signup/verify {"email", "code"} โ the response contains your key
(shown exactly once; re-verifying with the same email rotates it, which doubles as lost-key recovery).
Or sign up on the landing page.
# 1. create an inbox โ returns a working address in one call
curl -X POST https://maildesk.email/v0/inboxes \
-H "authorization: Bearer $KEY" -d '{"username":"my-agent"}'
# 2. read its mail (every inbound message carries an injection_score)
curl "https://maildesk.email/v0/inboxes/my-agent@maildesk.email/messages" \
-H "authorization: Bearer $KEY"
# 3. send from it
curl -X POST https://maildesk.email/v0/inboxes/my-agent@maildesk.email/messages \
-H "authorization: Bearer $KEY" \
-d '{"to":"someone@example.com","subject":"hello","text":"from my agent"}'
# 4. grab a verification code the moment it arrives
curl "https://maildesk.email/v0/inboxes/my-agent@maildesk.email/otp" \
-H "authorization: Bearer $KEY"
?since= is the poll cursor. It returns only messages with
created_at strictly greater than the value you pass, so you never re-read a message.
Keep the largest created_at you've seen and pass it back on the next tick.cursor=0
while true; do
batch=$(curl -s "https://maildesk.email/v0/inboxes/$INBOX/messages?direction=inbound&since=$cursor" \
-H "authorization: Bearer $KEY")
# ... process $batch (newest first) ...
new_cursor=$(echo "$batch" | jq '[.[].created_at] | max // empty')
[ -n "$new_cursor" ] && cursor=$new_cursor
sleep 15
done
Prefer an empty-inbox workflow? DELETE /v0/messages/:id each message after handling it
instead of tracking a cursor. Prefer push? Register a webhook for
message.received.
Anywhere a path says :inbox, the inbox id and the full
address are interchangeable.
/v0/inboxeswriteCreate an inbox. Body (all optional): username (local part โ lowercased, limited to
a-z0-9._-, random if omitted), name (human label), domain
(defaults to the default domain). Returns 201 with the Inbox,
including its address. 409 if the address exists.
/v0/inboxesreadList all inboxes.
/v0/inboxes/:inboxreadFetch one inbox.
/v0/inboxes/:inboxwriteDelete an inbox and everything in it (messages, threads, attachments).
/v0/inboxes/:inbox/messagesreadList messages, newest first. Query params:
| Param | Type | Meaning |
|---|---|---|
since | epoch‑ms | Only messages with created_at > since โ the poll cursor |
direction | inbound | outbound | Filter by direction |
limit | 1โ200 | Batch size (default 50) |
/v0/inboxes/:inbox/messageswriteSend an email from this inbox. Body: to (required), subject, text,
html. Returns 201 with {message, delivery}.
/v0/messages/:idreadOne message with full body, attachments, and injection-safety analysis.
/v0/messages/:idwriteDelete a message (and its attachments) once you've handled it โ it never reappears in a poll.
/v0/messages/:id/attachmentsreadAttachment metadata: {id, filename, content_type, size, has_content}.
/v0/messages/:id/attachments/:attachment_idreadRaw attachment bytes with the original content type. 410 if the content was too large to
store inline (metadata only).
| Field | Type | Notes |
|---|---|---|
id | string | msg_โฆ |
inbox_id | string | |
thread_id | string | Messages sharing a normalized subject share a thread |
direction | string | inbound or outbound |
from_addr / to_addr | string | |
subject | string? | |
text / html | string? | Real inbound mail is fully MIME-parsed (multipart, quoted-printable, charsets) |
preview | string? | Short plain-text excerpt for list views |
injection_score | int? | 0โ100 prompt-injection risk (inbound only) |
injection_level | string? | low / medium / high |
injection_flags | array | {code, weight, detail} โ explainable signals behind the score |
otp_code | string? | Auto-extracted verification code, if found |
labels | array | e.g. ["otp"] |
created_at | epoch‑ms | Use as the since cursor |
/v0/inboxes/:inbox/otpreadThe most recent auto-extracted verification code, or {"code": null} if none yet.
Pass ?since=<epoch-ms of when you triggered the send> to ignore older codes.
Response: {code, message_id, from, received_at}.
Every inbound message is scored 0โ100 for prompt-injection risk before your agent reads it. Gate tool use on it:
msg = get_message(id)
if msg.injection_level == "high":
quarantine(msg) # don't let the model act on it
elif msg.injection_level == "medium":
require_confirmation(msg)
else:
process(msg)
injection_flags names each signal (e.g. instruction_override,
secret_exfiltration, look-alike sender domains, hidden HTML) with its weight โ the score is
explainable, not a black box.
Push instead of poll. Events: message.received, message.sent, or *.
/v0/webhooksBody: url (required), secret (random UUID if omitted), events
(default ["*"]). Deliveries are signed: x-agentpost-signature is the hex
HMAC-SHA256 of the raw body with your secret.
/v0/webhooks/v0/webhooks/:id/v0/keysBody: name, scopes (subset of read/write/admin;
default ["read","write"]). The raw key appears once in the 201 response.
/v0/keys/v0/keys/:id/v0/meanyWhat key am I? Returns {name, scopes, master}.
/v0/domainsreadList configured domains (readable so you can pick one when creating inboxes). Writes โ
POST /v0/domains,
PATCH/DELETE /v0/domains/:domain,
POST /v0/domains/:domain/default โ require admin.
/v0/inboundwriteInject a fake inbound email for testing โ it runs the same pipeline as real mail (injection scoring,
OTP extraction, webhooks). Body: from, to (must match an existing inbox),
subject, text, html, optional attachments
[{filename, content_type, content_b64}]. Note: text is stored verbatim โ
real inbound mail is MIME-parsed before it reaches this pipeline.
Point any MCP client at POST https://maildesk.email/mcp with the same bearer key
(plain JSON-RPC 2.0 responses โ the broadly compatible subset of streamable HTTP). Tools:
| Tool | REST equivalent |
|---|---|
create_inbox | POST /v0/inboxes |
list_inboxes | GET /v0/inboxes |
list_messages | GET /v0/inboxes/:inbox/messages (supports since) |
get_message | GET /v0/messages/:id |
send_message | POST /v0/inboxes/:inbox/messages |
delete_message | DELETE /v0/messages/:id |
wait_for_otp | GET /v0/inboxes/:inbox/otp |
list_domains | GET /v0/domains |
Errors are JSON: {"error": "<what went wrong>", "docs": "https://maildesk.email/docs"}.
Resource misses name the resource ("inbox radar@โฆ not found"); a path that doesn't exist
returns 404 {"error": "no such route: GET /v0/whatever"} โ with or without a key, so the
surface is probeable. 401 means missing/invalid key, 403 names the missing
scope, 429 is a rate cap (see below).
| Cap | Self-serve key | Admin-issued key |
|---|---|---|
| New inboxes / day / key | 10 | 100 |
| Outbound sends / day / inbox | 50 | 200 |
Beta limits โ need more? hello@maildesk.email. Machine-readable pricing: /pricing.md.
AgentPost is a Shovelware product. Spec: /v0/openapi.json ยท Agent-readable summary: /llms.txt