Developers · September 21, 2026

Agent Email API: Send, Receive and Reply in One Loop

Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.

Most email APIs give you a send endpoint and call it a product. An agent needs the other half: it has to find out that mail arrived, read it, and reply into the same thread, without double-sending when the network hiccups. This is the whole loop over REST, with the request shapes and error codes the deployed agent email API actually uses.

If the concepts underneath are new — what an agent inbox is, why the agent gets its own address — start with email for AI agents and come back.

Everything below runs against https://api.emailforagents.ai, API version 2026-09-19. Credentials go in Authorization: Bearer. Every response carries an x-request-id header; log it, because it is the one identifier support can correlate.

The loop you actually need (not just a send endpoint)

Five moving parts:

  1. A scoped credential that can only touch the inboxes it needs.
  2. An inbox with a stable ID.
  3. A send path that is safe to retry.
  4. An inbound path — events, webhook, or both — that is safe to replay.
  5. A reply path that threads.

The rest of this article is those five in order. The one design rule that ties them together: every step is at-least-once, so every step needs a deduplication key. Sends dedupe on Idempotency-Key. Event processing dedupes on event ID. Neither substitutes for the other.

Scoping a key to one project and one inbox

Create a named key against a project, and — this matters — against specific inboxes:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/keys" \
  -H "Authorization: Bearer $EFA_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice-agent (send)",
    "scopes": ["inboxes:read","threads:read","messages:read","messages:send","events:read"],
    "inbox_ids": ["ibx_..."]
  }'

The secret comes back once, in secret, formatted efa_live_ (or efa_test_) plus eight hex characters plus a 43-character body. Store it server-side; there is no second chance to read it.

Three enforcement details worth designing around:

A credential cannot exceed its creator. Requesting a scope your own role lacks fails at creation with permission_denied, not later at use time.

Environment is carried in the prefix and cannot be crossed. An efa_test_ key resolves only to test projects and test inboxes in that workspace. There is no flag that lets it reach live.

Authority is re-read on every request. The token is a lookup handle, not a claim set. The effective permission is recomputed from current database state each time — intersected with the current grant of the human who authorised it. Remove that human’s project assignment and every key they minted narrows on the next request, with no revocation step and no cached-token window.

For agent-facing keys, start without messages:send. A credential holding drafts:submit but not messages:send can compose fully formed messages that always land in the approval queue.

Creating the inbox

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes" \
  -H "Authorization: Bearer $EFA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Invoice intake","local_part":"invoices"}'

Live platform projects return an address on agents.emailforagents.ai. Test projects reject local_part and domain_id with HTTP 422 test_mode_unsupported, and generate a non-routable .mail.invalid address instead. Keep the returned id (ibx_ plus a UUID) — it is in the path of nearly every other call.

Sending with an Idempotency-Key and what that guarantees

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
  -H "Authorization: Bearer $EFA_API_KEY" \
  -H "Idempotency-Key: invoice-4471-receipt-v1" \
  -H "Content-Type: application/json" \
  -d '{
    "to": [{"email":"ap@example.com","name":"Accounts Payable"}],
    "subject": "Receipt for invoice 4471",
    "text": "Attached is the receipt for invoice 4471.",
    "metadata": {"invoice_id":"4471"}
  }'

Shape notes that bite people: to is an array of objects with an email field, not a bare string. The schema is strict — an unknown top-level field is a 400, not a warning. subject is required, 1 to 998 characters. At least one of text or html must be present, each capped at 512,000 characters. Up to 25 unique recipients across To, Cc and Bcc combined. Up to 10 attachment_ids. The whole JSON body is capped at 1 MiB.

The Idempotency-Key header is mandatory. Without it the request fails with HTTP 400 invalid_request before anything is evaluated.

What the key guarantees is more precise than “no duplicates”: the request is normalised into a canonical payload and hashed, so the key is bound to that exact message, not to the endpoint. Reusing it with the same payload replays the stored response; reusing it with a changed payload is an error rather than a second email.

For this walkthrough you need two rules. Choose keys from business intent, not from the attempt — invoice-4471-receipt-v1 names the one thing you meant to send, whereas a fresh UUID per HTTP retry is precisely the failure the header exists to prevent. And treat a 409 as a bug in your key derivation, not as something to retry around.

The four outcomes of reusing a key, the 30-day replay window versus the 365-day tombstone, and why an unknown outcome must not be retried are all covered in idempotency keys: stop an agent sending duplicate emails. Read it before you ship a retry loop.

Both success paths return HTTP 202. Branch on result:

{
  "result": "queued",
  "message_id": "msg_...",
  "thread_id": "thd_...",
  "state": "queued",
  "request_id": "req_..."
}
{
  "result": "approval_required",
  "draft_id": "drf_...",
  "draft_version": 1,
  "approval_id": "apr_...",
  "reason_codes": ["always"],
  "request_id": "req_..."
}

reason_codes tells you why review was required: always from an inbox rule, new_recipient_domain, attachments, or draft_only_principal when the credential itself cannot send.

The states a send moves through

StateEventWhat it tells youWhat to do
queuedmessage.queuedStored and accepted for processing. Nothing has reached a provider.Wait.
approval pendingapproval.requestedNo email exists yet; a human must act.Surface it. Do not retry.
acceptedmessage.acceptedThe provider acknowledged the submission.Start tracking per-recipient delivery.
submission_unknownmessage.submission_unknownSubmission was interrupted; acceptance could not be confirmed.Do not auto-resend. Investigate.
failedmessage.failedA specific rejection was recorded.Read the reason, then decide.
cancelednone emitted in this buildCancelled before submission, e.g. sending paused.Nothing was sent. Read the message.
message.delivery_updatedA recipient’s delivery status changed.Per-recipient, not per-message.

Two of these deserve emphasis.

accepted is not delivered. It means one hop succeeded: the provider took the message. Recipient outcomes arrive later and independently. A message to five recipients has five delivery states, and they can disagree. Recipients start at pending.

submission_unknown is a real state, not a rounding error. When a submission is interrupted after the request goes out but before an acknowledgement returns, that is recorded as submission_unknown with the error code interrupted_submission, and the system deliberately stops rather than retrying. Your side of the contract is narrow: surface it, do not auto-resend it, and do not let your UI round it down to “failed” — that is how someone presses resend on a message that already went out. What each status proves sets out the diagnosis order, and the quota and billing consequences explain why the reservation is held rather than released.

Getting inbound mail: polling the events cursor vs webhooks

The event stream is the durable record. It is per-project and ordered:

curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=50" \
  -H "Authorization: Bearer $EFA_API_KEY"
{
  "data": [
    {
      "id": "evt_...",
      "type": "message.received",
      "sequence": 184,
      "occurred_at": "2026-09-21T09:14:02.118Z",
      "data": { "message_id": "msg_...", "thread_id": "thd_...", "inbox_id": "ibx_..." },
      "environment": "live",
      "api_version": "2026-09-19"
    }
  ],
  "next_cursor": "184",
  "has_more": false
}

The cursor is the project sequence number as a decimal string. limit is 1 to 100, default 25. Persist next_cursor after you have processed and committed the batch, not when you receive it. If your stored cursor falls behind the oldest retained event you get HTTP 410 cursor_expired with retryable: false — restart without a cursor rather than looping.

The 18 event types cover inbox lifecycle (inbox.created, inbox.updated, inbox.deleted), domains (domain.updated), mail (message.received, message.queued, message.accepted, message.delivery_updated, message.failed, message.submission_unknown, message.canceled, message.quarantined), approvals (approval.requested, approval.approved, approval.rejected, approval.invalidated), plus webhook.test and project.sending_paused.

Four of those eighteen are declared but not emitted by the current build: message.canceled, message.quarantined, approval.approved and approval.invalidated. Subscribing to them is accepted and simply never fires, which is a miserable way to spend an afternoon. Check the event table before you build a handler, and see what each message status proves for the states that have no event behind them.

Webhooks give you the same events pushed to an HTTPS endpoint. Register one, subscribe to the types you want, and store the signing secret — shown once. Each callback carries three headers: webhook-id (the event ID), webhook-timestamp (Unix seconds), and webhook-signature, which is v1, followed by the base64 HMAC-SHA256 of id.timestamp.rawBody keyed on your secret. Verify against the exact raw bytes, before parsing, and reject anything more than five minutes off. The verification code is in the events and webhooks docs.

Delivery is retried up to eight attempts, with a fixed backoff of 10s, 60s, 5m, 30m, 2h, 6h and 12h. A 4xx response is terminal except for 408, 409, 425 and 429. That means a duplicate callback is normal, not exceptional: deduplicate on the event ID before doing any work.

Use both. The webhook is your latency path; the cursor is your recovery path when the endpoint was down. Do not build only the webhook — missed events are not automatically replayed when an endpoint is re-enabled, and the cursor is how you catch up.

Replying into the existing thread

Pass the inbound message’s ID as reply_to_message_id, and supply recipients and subject explicitly. The parent message’s thread is reused and the reply gets the next sequence number in it:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
  -H "Authorization: Bearer $EFA_API_KEY" \
  -H "Idempotency-Key: reply-to-msg_7f21c-v1" \
  -H "Content-Type: application/json" \
  -d '{
    "to": [{"email":"ap@example.com"}],
    "subject": "Re: Receipt for invoice 4471",
    "reply_to_message_id": "msg_7f21c...",
    "text": "Corrected receipt attached."
  }'

The parent must exist in the same inbox, and the credential needs messages:read in addition to its send scope — replying is a read of the thread as well as a write to it. reply_to_message_id is part of the canonical payload, so two replies to different parents can never collide on one idempotency key even with identical bodies.

That one field does the threading work, but it is not the whole story once mail leaves this system: In-Reply-To and References are resolved at submission time and the receiving client decides what to group. Keeping an agent’s replies in the right thread covers the header mechanics and the ways threading actually breaks.

To render a conversation, page the thread:

GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads?q=invoice&limit=25
GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads/{thread_id}/messages?limit=25

q searches stored subjects, senders and previews. It does not search full message bodies or attachment contents — useful for finding a conversation, not for retrieval over corpus text.

Attachments inside the loop

Attachments are three calls, then a wait:

POST /v1/projects/{project_id}/uploads              → { "id": "upl_...", ... }
PUT  /v1/projects/{project_id}/uploads/{upload_id}/content
POST /v1/projects/{project_id}/uploads/{upload_id}/complete

The reservation declares inbox_id, filename, declared_bytes and media_type, and optionally a sha256 you expect. declared_bytes must be at most 10 MiB — larger returns HTTP 413 payload_too_large. After complete, the file is in scan_pending, not ready. Poll GET .../uploads/{upload_id}.

Sending with an attachment that has not cleared returns HTTP 422 attachment_not_ready. This is a feature: a pending scan must block the send, because silently dropping the file would transmit something other than what the agent intended. Ten attachments per message, 10 MiB each, 15 MiB combined, and reservations expire after 24 hours. Over MCP, attachment IDs can only be reused from the same inbox — the MCP surface does not upload files at all.

Which errors are retryable

The envelope tells you directly:

{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Retry shortly.",
    "request_id": "req_...",
    "retryable": true,
    "details": {}
  }
}

retryable is true only for rate_limited, provider_unavailable and temporarily_unavailable. Everything else is false unless the server says otherwise, and a false means retrying the identical request will produce the identical error.

Retry, with backoff and the same idempotency key:

Do not retry — fix something instead:

Promoting the same code from test to live

The code does not change. The credential does.

A project’s environment is fixed at creation and cannot be flipped — attempting it returns environment_immutable. So you keep two projects and swap the key. Because the environment is encoded in the key prefix and enforced server-side, an efa_test_ key pointed at a live project ID simply cannot reach it. That is the property that makes this safe: a misconfigured deploy fails closed rather than emailing a customer.

What genuinely differs in live:

Before you make the switch, run the sandbox checklist in test an agent’s email without sending real mail, and make sure your retry logic matches the idempotency rules. If your agent connects through a client rather than your own code, the MCP path reaches the same pipeline with the same policy.


Keep reading

All guides · Documentation · Developers