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:
- A scoped credential that can only touch the inboxes it needs.
- An inbox with a stable ID.
- A send path that is safe to retry.
- An inbound path — events, webhook, or both — that is safe to replay.
- 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
| State | Event | What it tells you | What to do |
|---|---|---|---|
queued | message.queued | Stored and accepted for processing. Nothing has reached a provider. | Wait. |
| approval pending | approval.requested | No email exists yet; a human must act. | Surface it. Do not retry. |
accepted | message.accepted | The provider acknowledged the submission. | Start tracking per-recipient delivery. |
submission_unknown | message.submission_unknown | Submission was interrupted; acceptance could not be confirmed. | Do not auto-resend. Investigate. |
failed | message.failed | A specific rejection was recorded. | Read the reason, then decide. |
canceled | none emitted in this build | Cancelled before submission, e.g. sending paused. | Nothing was sent. Read the message. |
| — | message.delivery_updated | A 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:
429 rate_limited— the response carriesretry-after: 60. The deployed budgets are 600 requests per 60 seconds per principal and per workspace, behind a per-IP edge budget of 1,200 per 60 seconds. These counters are per-location and permissive, so treat them as a ceiling to stay under, not a quota to spend.503 provider_unavailable/503 temporarily_unavailable— transient. Note thattemporarily_unavailableis also what you get when a send with your key is already in flight.
Do not retry — fix something instead:
400 invalid_request—details.fieldsnames the bad fields.401 unauthenticated— revoked, expired, or the authorising human lost access.402 payment_required/402 quota_exceeded— no allowance left.403 permission_denied/403 policy_denied— scope, or policy.policy_deniedcarriesdetails.reason_codes:send_disabled,blocked_recipient,recipient_not_allowed.403 daily_limit_exceeded— the inbox’s daily recipient cap. It resets at midnight UTC. Test sends never consume it.409 idempotency_conflict— same key, different content. Your bug.412 version_conflict— the draft or policy changed. Re-read, then decide.422 recipient_suppressed,422 domain_not_ready,422 attachment_not_ready— preconditions, not blips.410 cursor_expired— restart the event scan from the beginning.
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:
- Recipients are real, and suppression applies (
recipient_suppressed). - The daily recipient cap and the monthly allowance apply. Test sends consume neither.
- The provider path is real, which is where
acceptedandsubmission_unknownstart to mean something. - 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.
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
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown — what each email status actually proves, what it does not, and the correct next action for every one of them.
Handle Email Webhooks Without Processing Events Twice
Verify the signature against the raw body, deduplicate by event ID, enqueue durably, and keep a webhook retry from re-sending the email that produced it.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.