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
{
"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, 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 onPOST /v1/inboxesfails with 403quota_exceeded.webhook_endpoints— structural cap on webhook endpoints. Exceeding it onPOST /v1/webhook-endpointsfails with 403quota_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 onPOST /v1/inboxes/{inbox}/messagesfails with 429quota_exceededplus aRetry-Afterheader counting down to the reset.outbound_daily_shared_domain: how many messages this customer may send per UTC day from the shared domainsagentmail.ackuna.comandagents.ackuna.com(existing inboxes only) together, across every workspace on the account (100).usedis the account total;workspace_usedis this workspace's share. Sending more needs an inbox on a custom domain. Exceeding it fails with 429quota_exceeded,error.quota=outbound_daily_shared_domain, and aRetry-Afterto 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 onPOST /v1/inboxes/{inbox}/messagesfails with 429quota_exceededplus aRetry-Afterheader 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: trueheader — the side effect does not repeat. One-shot credentials (a webhook endpoint'ssecret) 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.