# Authentication

Every `/v1` request authenticates with a bearer API key: `Authorization: Bearer ak_...`.
There is no session, cookie, or OAuth flow.

```
GET /v1/me
Authorization: Bearer ak_9f2c1e7b4a6d8035f1c9a2b7e4d6f803a1b2c3d4
```

```json
{
  "data": {
    "object": "account",
    "organization": {"id": "org_01k2n9v8qf3m7d5b0y6xz4rt1c", "name": "Acme Robotics"},
    "api_key": {"id": "key_01k2n9v8qf3m7d5b0y6xz4rt2d", "name": "prod agent", "scopes": ["inboxes:read", "messages:read", "messages:send"]},
    "access": "active",
    "quotas": {
      "inboxes": {"limit": 3, "used": 0},
      "outbound_daily": {"limit": 100, "used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_daily_shared_domain": {"limit": 100, "used": 0, "workspace_used": 0, "resets_at": "2026-08-21T23:59:59.999999Z"},
      "outbound_monthly": {"limit": 3000, "used": 0, "scope": "service", "service_used": 0, "resets_at": "2026-08-31T23:59:59.999999Z"},
      "webhook_endpoints": {"limit": 1, "used": 0},
      "custom_domains": {"limit": 0, "used": 0}
    }
  }
}
```

`GET /v1/me` requires no scope — a key must always be able to discover what it can do, even
one with no scopes at all. Call it first in any new integration.

## Keys

A key is issued once — by [sign-up](signup), in the console under Developers → API keys, or
by an operator — and shown in plaintext exactly that one time: `ak_` followed by 40 random
characters. It is stored server-side only as a hash — there is no way to retrieve the
plaintext again; issue a new key if it is lost. A key can carry an `expires_at`; once past,
every request with it fails with 401 `unauthenticated`, the same as a revoked or malformed
key.

## Access states

Every `/v1` request is checked against the organization's `agent_mail_access` before scopes:
`active` passes; `none` answers `403 agent_mail_not_enabled`; `suspended`
answers `403 organization_suspended`. `access` on `GET /v1/me` reports the current value.

## Scopes

Each key carries a list of scopes, checked per-endpoint after the access check passes:

| Scope | Grants |
|---|---|
| `inboxes:read` | List and fetch inboxes |
| `inboxes:write` | Create and update inboxes |
| `messages:read` | Read threads, messages and attachments |
| `messages:send` | Send a message from an inbox, and retry a failed one |
| `webhooks:read` | List webhook endpoints and their deliveries |
| `webhooks:write` | Create, update, delete, rotate, and redrive webhooks |
| `domains:read` | List and fetch receiving domains |
| `domains:write` | Register and delete receiving domains |

A key missing a required scope gets 403 `insufficient_scope`, and `message` names exactly
which scope was missing so the failure is actionable without guessing. `GET /v1/me`
reports the key's current scopes under `api_key.scopes`.

## Rate limits

Two independent limiters apply to every `/v1` request except sign-up, checked before scopes:

- **Per IP address:** 120 requests/minute. Applies even to unauthenticated requests, so a
  bad key cannot be brute-forced past it.
- **Per API key:** 60 requests/minute for a valid key. A request whose key does not resolve
  is counted against its IP address instead.

Either limit failing returns 429 `rate_limited` with a `Retry-After` header giving seconds
until the window resets.

Sending has a third limit of its own. `POST /v1/inboxes/{inbox}/messages` and
`POST /v1/messages/{message}/retry` also count against an hourly send limit for the key's
workspace. Once the workspace has been reviewed, the limit comes from its plan: 13 send
requests an hour on Free, 250 on Builder, 1,250 on Scale and 6,250 on Startup. Until then
it is 100 an hour, though a workspace not yet reviewed is held to a much smaller daily
quota anyway. Over it, the send fails with 429 `rate_limited` and a `Retry-After`
header giving seconds until the window resets.

- **It counts requests, not messages delivered.** Every call that gets past the per-IP and
  per-key limits counts, whether it is then accepted, refused (a 403 access or scope
  refusal, a 422 validation error, a 429 quota) or a replay of an earlier `Idempotency-Key`.
  A request stopped by the per-IP or per-key limit never reaches this one.
- **It is one count per workspace.** All of the workspace's keys share it, so it is not
  multiplied by the number of keys, and the sends made from that workspace in the console
  share it too.
- **A key that does not resolve** belongs to no workspace. Its send requests are counted
  against a per-IP bucket of 60 requests a minute instead, and are then refused with 401.

`POST /v1/signup` and `POST /v1/signup/verify` take no key and are not covered by any of
these limits; they carry their own, stricter ones — documented in the OpenAPI description.

## Quotas

Beyond rate limits, six quotas apply, and all are visible on `GET /v1/me` under `quotas`.
Two of them are counted across the whole customer account rather than this workspace
alone: `outbound_daily_shared_domain`, and `outbound_monthly` when the plan's allowance is
pooled (`scope` is `pooled`; `service_used` is then Agent Mail's own share of `used`):

- **`inboxes`** — structural cap on how many inboxes the organization may create.
  Exceeding it on `POST /v1/inboxes` fails with 403 `quota_exceeded`.
- **`webhook_endpoints`** — structural cap on webhook endpoints. Exceeding it on
  `POST /v1/webhook-endpoints` fails with 403 `quota_exceeded`.
- **`outbound_daily`** — how many messages the organization may send per UTC day. A message
  uses its slot when it is accepted, and keeps it if the message later fails.
  Exceeding it on `POST /v1/inboxes/{inbox}/messages` fails with 429 `quota_exceeded` plus a
  `Retry-After` header counting down to the reset.
- **`outbound_daily_shared_domain`**: how many messages this customer may send
  per UTC day from the shared domains `agentmail.ackuna.com` and `agents.ackuna.com`
  (existing inboxes only) together, across every
  workspace on the account (100). `used` is the account total;
  `workspace_used` is this workspace's share. Sending more needs an inbox on a
  [custom domain](domains). Exceeding it fails with 429 `quota_exceeded`,
  `error.quota` = `outbound_daily_shared_domain`, and a `Retry-After` to UTC
  midnight.
- **`outbound_monthly`** — how many messages the organization may send per calendar month
  (UTC); on a pooled plan the count includes the customer's other services. It counts
  messages the mail provider has accepted, and a send is also refused when the messages
  still waiting to be sent would take it over the allowance. Exceeding it
  on `POST /v1/inboxes/{inbox}/messages` fails with 429 `quota_exceeded` plus a
  `Retry-After` header counting down to the first of next month.
- **`custom_domains`**: structural cap on registered receiving domains.

Structural caps do not clear with time — only a larger grant helps. The daily and monthly caps always
clear at their next reset times.

## Idempotency

Every mutating endpoint (`POST`, `PATCH`, `DELETE`) accepts an optional
`Idempotency-Key` header — any client-generated string up to 255 characters:

```
POST /v1/inboxes
Idempotency-Key: create-support-inbox-1
```

- First request with a key: processed normally, response stored against the key.
- Same key, same method/path/body, repeated: the stored response is replayed with an added
  `Idempotent-Replay: true` header — the side effect does not repeat. One-shot credentials
  (a webhook endpoint's `secret`) are not part of the stored copy, so a replay omits them:
  capture them from the first response.
- Same key, different body (or a different endpoint): 409 `idempotency_key_reuse`.
- Same key, original request still in flight: 409 `idempotency_key_in_progress`.

The header is optional — omitting it processes every request normally — but strongly
recommended on anything that sends mail or creates a resource, since retries after a
network timeout are otherwise indistinguishable from a second, deliberate request. Stored
keys expire after 24 hours.
