# Quickstart: send your first agent email in 2 minutes
URL: https://emailforagents.ai/docs/quickstart
Summary: Create an inbox, connect an MCP client or mint a scoped API key, and send your first agent email over REST. Real request shapes and error codes, no live mail.

1. [Home](https://emailforagents.ai/)
2. [Docs](https://emailforagents.ai/docs)
3. Quickstart

Quickstart

## Send your first agent email

One inbox, one credential, one request. Do it in a test project first: test inboxes are non-routable, so nothing you get wrong can reach a real person.

Last reviewed September 21, 2026 against the
implementation in this repository.

This is the shortest complete path through the
[agent email API](https://emailforagents.ai/product/developers). The reference behind it — every error code,
every enforced limit, the full scope list — is on the
[documentation overview](https://emailforagents.ai/docs).

### Before you start

- **A workspace and a project.** A project is either `test` or
`live` and cannot be changed afterwards, so create a test one to begin with.

- **Decide the permission before you connect anything.** Read, read + draft, and
read + send are different blast radiuses. Read + draft cannot transmit mail at all.

- **Know what free covers.** Test mode allows 3 active inboxes, 100 retained
messages and 10 MiB per workspace. The live allowance is one complimentary live inbox and 100
email units a month per verified owner identity, claimed by that identity's first eligible
workspace.

1

### Create the inbox

Sign up, create a project, then create an inbox in it. What you get back depends on the
project's environment.

```
Test project  →  a1b2c3d4-…@8e7f6a5b-….mail.invalid   (generated, non-routable)
Live project  →  ada@agents.emailforagents.ai            (real address)
```

Test addresses are generated. Asking for a custom local part or a domain on a test project
returns `test_mode_unsupported` — that restriction is what makes test mode safe
to point an unproven agent at. Your own domain is a
[separate, later step](https://emailforagents.ai/docs/domains) and needs a paid subscription.

[Create an inbox](https://app.emailforagents.ai/sign-up)

2

### Give your agent access

Two credentials, and they are not interchangeable. An MCP client uses an OAuth connection
grant; your own code uses a project-scoped API key.

```
https://mcp.emailforagents.ai/mcp
```

1.
Open **Access** and paste that URL into your client's remote MCP server
settings.

2. Start the connection in that client. Sign in when the browser window opens.
3.
Check the client name, pick exactly which inboxes to share, and choose
**read**, **read + draft** or **read + send**.

4.
Ask the client to list the inbox. Access then shows the grant and when it was last used.

The grant exposes six tools: `list_inboxes`, `list_threads`,
`list_messages`, `get_message`, `create_draft` and
`send_message`. A client only sees the tools its preset covers — a
`read` grant is not shown `send_message` at all, rather than being
shown it and refused. Grants expire after 30 days and can be revoked from Access at any
time.

**An API key will not work at `/mcp`.** An
`efa_` key, and a signed-in console session, are both rejected there by design:
only a connection grant may invoke MCP tools. Remote MCP with OAuth is required, and no
named client has been certified — connect one and verify it yourself in a test project.

3

### Or use the REST API

Create a named key in **Access**, save the one-time secret server-side, then
list the inboxes it can see:

```
curl https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes \
  -H "Authorization: Bearer $EFA_API_KEY"
```

This reads only; it sends nothing. The response is
`data` / `next_cursor` / `has_more`, the same shape as every
other list endpoint. Copy an inbox ID — it starts with `ibx_` — into
`EFA_INBOX_ID`.

4

### Send and watch it land

The send route is nested under both the project and the inbox, `to` is an array
of recipient objects, and the `Idempotency-Key` header is required.

```
curl -X POST \
  "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
  -H "Authorization: Bearer $EFA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "to": [{ "email": "you@example.com", "name": "You" }],
        "subject": "Hello from the agent",
        "text": "First one."
      }'
```

```
HTTP/1.1 202 Accepted

{
  "result": "queued",
  "message_id": "msg_2f1c...",
  "thread_id": "thd_9b04...",
  "state": "queued",
  "request_id": "req_7a3e..."
}
```

**queued** means the request was accepted and a send intent exists — not that
anything has been delivered. The message moves `queued` →
`submitting` → `accepted`, and acceptance by the provider is still
not delivery. Watch the thread, or the
[events stream](https://emailforagents.ai/docs/events-and-webhooks), for what actually happened.

If the inbox requires review, or the credential is draft-only, you get
`approval_required` instead and nothing has been sent:

```
{
  "result": "approval_required",
  "draft_id": "drf_5c22...",
  "draft_version": 1,
  "approval_id": "apr_0e8d...",
  "reason_codes": ["always"],
  "request_id": "req_7a3e..."
}
```

Replaying the same `Idempotency-Key` with a byte-identical payload returns the
stored response rather than creating a second message, for 30 days. Replaying it with
different content returns `idempotency_conflict` — the key is bound to a hash of
the canonical payload, so editing the subject and retrying is a new send, not a retry. There
is one outcome you must not blind-retry: a submission whose result is unknown. See
[idempotency keys for agent email](https://emailforagents.ai/blog/retrying-agent-email-safely).

5

### Prove the inbound half

A one-way integration is half an integration. In a test project you can synthesise a
received message without any real mail, which is enough to exercise your reply path,
webhooks and approval flow end to end:

```
curl -X POST \
  "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/test/inbound" \
  -H "Authorization: Bearer $EFA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "inbox_id": "ibx_8e7f6a5b-2c14-4a77-9d3e-0b51f7a2c8de",
        "from": { "email": "buyer@example.com" },
        "to": [{ "email": "a1b2c3d4-...@8e7f6a5b-....mail.invalid" }],
        "subject": "Invoice 4821",
        "text": "Could you resend the receipt?"
      }'
```

That records an inbound message and emits `message.received` with
`simulated: true`. Then poll the project's event stream:

```
curl "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=25" \
  -H "Authorization: Bearer $EFA_API_KEY"
```

Persist `next_cursor` only after you have processed the events it covers. To
reply in-thread, pass the inbound message's ID as
`reply_to_message_id` on your send; the API sets
`In-Reply-To` and `References` from the stored parent so the reply
threads in the recipient's client.

### Reading the errors

Branch on `error.code` rather than the HTTP status — several codes share a status.
The ones you are most likely to meet first:

| Code | HTTP | What to do
| `unauthenticated` | 401 | The key is wrong, revoked, or you sent a console token. Mint a fresh key.
| `permission_denied` | 403 |
The key is missing a scope, or the project or inbox in the path is outside its grant.
Check all three.

| `policy_denied` | 403 |
Policy blocked it. `details.reason_codes` says which rule:
`send_disabled`, `blocked_recipient` or
`recipient_not_allowed`. Retrying will not help.

| `payment_required` / `quota_exceeded` | 402 | No entitlement or no allowance left. A retry cannot fix this either.
| `test_limit_exceeded` | 403 | You are at 3 test inboxes, 100 retained messages or 10 MiB. Delete something.
| `invalid_request` | 400 |
Usually a missing `Idempotency-Key`, or `to` sent as a string
instead of an array of objects. `details.fields` names the offenders.

| `idempotency_conflict` | 409 | The same key with different content. Use a new key for new content.
| `attachment_not_ready` | 422 |
A file has not passed its scan. See [attachments](https://emailforagents.ai/docs/attachments).

| `rate_limited` | 429 | Back off. A `retry-after: 60` header is sent.

`approval_required` is not an error: it is a `202` response body meaning
the message is waiting for a human in Approvals.

### Next steps

Put a human in front of the first live send with
[recipient allow-lists and approvals](https://emailforagents.ai/docs/sending-controls), then stop polling and
[verify signed webhooks](https://emailforagents.ai/docs/events-and-webhooks) instead. When files are
involved, read [how attachments are scanned before they can be sent](https://emailforagents.ai/docs/attachments).

Longer form: [the agent email API loop, from send to reply](https://emailforagents.ai/blog/agent-email-api-loop) and
[how to give an AI agent its own email address](https://emailforagents.ai/blog/give-ai-agent-email-address).

[Previous Overview](https://emailforagents.ai/docs) [Next Sending controls](https://emailforagents.ai/docs/sending-controls)
