Docs

Email for Agents documentation

Give an AI agent its own email address, then let it read, draft and send through a scoped credential you can revoke. This page is the shared reference the rest of the documentation builds on; every number below is read out of the implementation, not the marketing copy.

If you have not created an inbox yet, start with the two-minute quickstart. If you are deciding whether to build on the REST API or connect over MCP, the agent email API overview covers the product surface and MCP or API key for agent email compares the two credential models side by side.

Surfaces and base URLs

SurfaceBase URLWho calls it
REST API https://api.emailforagents.ai Your own code, with a project-scoped API key
MCP server https://mcp.emailforagents.ai/mcp An MCP client holding an OAuth connection grant
Console https://app.emailforagents.ai A signed-in human: inboxes, approvals, keys, grants, policy

The API version string is 2026-09-19. It appears on every event record and on every webhook body, so a consumer can branch on it rather than guessing from the payload shape.

Authenticating a request

There are two machine credentials and they are not interchangeable. Which one you hold decides which surface you may call.

Project-scoped API keys

Create a key in Access. The plaintext is shown once. Its shape is efa_test_<8 hex>_<43 base64url characters> for a test project and efa_live_… for a live one, so the environment is legible in a log line without resolving the key. Send it as Authorization: Bearer efa_live_…. Only the digest is stored, which is why a lost key must be replaced rather than recovered. Revoke one with POST /v1/keys/{key_id}/revoke.

Connection grants for MCP

MCP clients authenticate with an OAuth connection grant, obtained through a browser consent step in which a human picks the specific inboxes and the permission preset. An efa_ API key presented at /mcp is rejected, and so is a console session token: only a principal of type connection may invoke MCP tools. That is deliberate, not a gap — an MCP client is software a human authorised for named inboxes, and the grant carries that authorisation. The server identifies itself as email-for-agents-mcp 1.0.0 and negotiates protocol versions 2025-11-25, 2025-06-18 and 2025-03-26. A GET to the endpoint returns 405 with Allow: POST.

No MCP client is certified or one-click here. Connections are recorded with a trust class of unverified unless an operator has classified them otherwise, and no client has been through a verification process. Connect one, then prove a round trip in a test project before you rely on it.

Scopes and connection presets

Authority is re-derived on every request and intersected with the authorising human's current grant, so demoting a member or revoking a key narrows what an existing credential can do without any cache to wait for. There are 32 scopes; sending uses messages:send, submitting a draft for review uses drafts:submit, and reading events uses events:read.

MCP consent offers three presets, and each maps to a fixed scope set:

PresetOAuth scopeScopes granted
read email.read inboxes:read, threads:read, messages:read, attachments:read, events:read
read_draft email.draft the read set plus drafts:read, drafts:write, drafts:submit
read_draft_send email.send the read_draft set plus messages:send

Administrative scopes can never be attached to a connection: keys:manage, members:manage, billing:manage, policies:write, inboxes:delete, messages:delete, messages:raw and exports:write are refused on a grant. A connection holding only drafts:submit is treated as draft-only: its sends become approval requests even when the inbox permits direct sending. See sending controls and approvals for how that interacts with inbox policy.

Resource ID prefixes

Every identifier is a prefix plus a UUID, and the prefix is validated on input. Passing a thread ID where an inbox ID belongs fails immediately rather than resolving to something plausible.

PrefixResourcePrefixResource
ws_Workspaceprj_Project
ibx_Inboxthd_Thread
msg_Messagedrf_Draft
apr_Approvalatt_Attachment
upl_Uploaddom_Domain
key_API keycon_Connection
evt_Eventwh_Webhook endpoint
exp_Export jobdel_Deletion job

Inbox IDs use ibx_. Most routes are nested under both the project and the inbox — for example POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages — because a credential is scoped to projects and inboxes, and the path is what that scope is checked against.

Request and response rules

  • Sends require an Idempotency-Key header. Without one the request is rejected with invalid_request before anything is created. See idempotency keys for agent email for the replay semantics.
  • JSON bodies are capped at 1 MiB and read through a bounded reader, so an oversized body is refused with payload_too_large rather than buffered. Attachment content uploads are capped at 10 MiB instead.
  • List endpoints return data, next_cursor and has_more. The default page is 25 items and limit accepts 1 to 100. A thread's message list defaults to 50. Cursors are covered in search and pagination.
  • A successful send returns HTTP 202 with result: "queued", or result: "approval_required" when policy or a draft-only credential routes it to review. Neither means the message left the building yet.
  • Every error shares one envelope and carries a request ID you can quote in a support thread.
{
  "error": {
    "code": "policy_denied",
    "message": "Workspace policy blocked this send.",
    "request_id": "req_...",
    "retryable": false,
    "details": { "reason_codes": ["blocked_recipient"] }
  }
}

retryable is computed, not decorative: it is true for rate_limited, provider_unavailable and temporarily_unavailable, and false everywhere else unless the specific call sets it. Retrying a non-retryable error changes nothing except your rate-limit budget.

Error codes and HTTP statuses

The whole set, with the status each one maps to. Branch on error.code, not on the status: several codes share a status and mean different things.

CodeHTTPWhat it means
invalid_request400The body or headers do not match the schema.
invalid_cursor400The cursor is malformed or belongs to another filter.
cursor_expired410The events you were paging through are no longer retained. Restart without a cursor.
unauthenticated401Missing, malformed or revoked credential.
payment_required402The action needs an entitlement this workspace does not have.
quota_exceeded402A plan quota, such as the custom-domain count, is used up.
permission_denied403The credential lacks the scope, project or inbox for this call.
policy_denied403Inbox, project or workspace policy blocked the send. Read details.reason_codes.
daily_limit_exceeded403The daily recipient cap for this inbox is exhausted.
test_mode_required403A simulation endpoint was called against a live project.
test_limit_exceeded403A test-mode cap would be exceeded: 3 active inboxes, 100 retained messages or 10 MiB.
not_found404No such resource, or none you are authorised to see.
source_unavailable404The underlying content could not be read.
idempotency_conflict409The key was reused with a different canonical payload.
idempotency_expired409The 30-day replay window for that key has closed.
address_unavailable409The requested address is taken or reserved.
send_already_submitting409A send with this key is in flight.
last_owner_required409The change would leave the workspace with no owner.
version_conflict412Someone else changed the policy, endpoint or draft. Re-read and resubmit.
payload_too_large413Body, upload or attachment set exceeds its limit.
recipient_suppressed422A recipient is on the suppression list after a hard bounce or complaint.
domain_not_ready422Ownership, sending or receiving verification has not completed.
attachment_not_ready422A file has not finished a successful scan.
test_mode_unsupported422The action does not exist in test mode, such as attaching a domain.
reserved_address422The local part is reserved by the platform.
environment_immutable422A project cannot move between test and live.
rate_limited429Too many requests. A retry-after: 60 header is sent.
provider_unavailable503The upstream mail provider could not be reached.
temporarily_unavailable503The service, or a dependency it needs to stay safe, is unavailable.

Limits enforced in code

These are checked on the request path, not published as guidance. Exceeding one returns an error rather than truncating your input.

LimitValue
JSON request body1 MiB
text and html body512,000 characters each
Subject998 characters, and no CR or LF
Unique recipients per message (To + Cc + Bcc)25
Attachments per message10
Bytes per attachment10 MiB
Aggregate attachment bytes per message15 MiB
Inbound raw MIME accepted25 MiB
List page25 by default, 100 maximum
Recipient allow or block entries per list100
Idempotency replay window30 days
Idempotency tombstone365 days
Approval expiry7 days
Reserved upload expiry24 hours
Minimum interval between webhook test events60 seconds
Active test inboxes per workspace3
Retained test messages per workspace100
Retained test bytes per workspace10 MiB
Free live storage per workspace100 MiB

The free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, claimed by that identity's first eligible workspace. Creating a second workspace does not renew it. Pricing has the full wording.

Rate limits

Three counters run in production: 1,200 requests per 60 seconds per client IP at the edge, 600 per 60 seconds per principal, and 600 per 60 seconds per workspace. Exceeding any of them returns rate_limited with retry-after: 60.

Treat those numbers as a ceiling rather than a contract. The counters are Cloudflare rate-limit bindings, which are local to the edge location serving the request and deliberately permissive; the code that reads them says as much. They are protection against a runaway loop, not a metering system, and they are never billing authority.

Test mode and live mode

A project is created as test or live and cannot move between them; the attempt returns environment_immutable. The environment decides the transport.

Test projectLive project
Address Generated, on a non-routable .mail.invalid host A real address on agents.emailforagents.ai or your own verified domain
Transport A local fake provider. Nothing reaches the internet. The upstream mail provider
Inbound POST /v1/projects/{project_id}/test/inbound synthesises a received message Real mail delivered to the address
Caps 3 active inboxes, 100 retained messages, 10 MiB Entitlement, storage and daily recipient caps
Custom domainsNot supportedRequires a paid subscription

Test inboxes accept no custom local part or domain: the address is generated, and asking for one returns test_mode_unsupported. That is what makes test mode safe to point an unproven agent at. Testing an agent's email without sending real mail walks through a full rehearsal.

What is not available yet

  • Paid checkout is disabled. Builder and Team are proposed prices and cannot be bought today. Anything gated behind a paid subscription — custom domains, higher quotas — is therefore gated in practice too.
  • No published SDK. There is no package to install; the examples here are curl and plain HTTP on purpose.
  • No certified MCP client. Generic remote MCP with OAuth is the supported shape; no named client has been verified.
  • Customer-triggered event replay is not exposed. Recover missed events with the durable events cursor instead.
  • Several event types are declared but not emitted by this build. The event catalogue marks which is which, so you do not build a branch that never runs.
  • There is no published uptime SLA and no status feed. Provider acceptance is also not proof of delivery; see status and security.

Where to go next