Authentication
API keys, scopes and permissions.
Every request carries a bearer key:
curl https://api.carlyemail.com/v0/inboxes \
-H "Authorization: Bearer ce_us_..."
Keys are stored as a SHA-256 hash. The plaintext is returned once, at creation, and cannot be recovered — rotate rather than retrieve.
Scopes
A key resolves to one of three scopes, and the scope is fixed at creation.
organization scope
Reaches every inbox and pod in the organization. What sign-up returns.
pod scope
Reaches one pod. Useful when a pod is one of your customers — see pods.
inbox scope
Reaches exactly one mailbox.
Inbox-scoped keys are the interesting case: they are what you hand to an
individual agent, so that a prompt injection can at worst misuse one mailbox
rather than the whole organization. A key scoped to an inbox that asks for a
sibling inbox gets inbox_out_of_scope, not an empty list.
Permissions
A key may also carry a permission map — message_send, inbox_create,
api_key_create and so on.
Permissions are a whitelist, never a merge. A key with no permission object has everything its scope allows. The moment a key carries any explicit permission, that object is the complete grant and anything absent is denied. There is no default map that fills in the gaps, because falling back to one is how a read-only agent key silently gains the ability to send.
Scope always wins over permissions. An inbox-scoped key cannot hold
organization-level capabilities like inbox_create even if its permission map
sets them true — otherwise an agent handed one mailbox could mint itself another.
Creating narrower keys
An organization-scoped key:
curl -X POST https://api.carlyemail.com/v0/api-keys \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "reader",
"permissions": { "message_read": true }
}'
A key pinned to one inbox. The scope is the URL, not a field in the body:
curl -X POST https://api.carlyemail.com/v0/inboxes/hello@carlyemail.com/api-keys \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "support agent",
"permissions": { "message_read": true, "message_send": true }
}'
A key can only delegate permissions it holds itself, so a restricted key cannot mint a broader one.
Expiry
A key lives until it is revoked, unless it was made with an expires_at:
curl -X POST https://api.carlyemail.com/v0/api-keys \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{"name": "one-off import", "expires_at": "2026-10-01T00:00:00Z"}'
After that time every request with it returns 401 api_key_expired, which is
its own code so a caller still holding the key in a config file knows the fix
is a new key rather than a closer look at this one. An expiry cannot be
extended; the key stays in the list, marked, until it is revoked.
Which key is which
Every email-code sign-in — in the console or with carlyemail signin — mints
a new organization key, named Email sign-in with the date and time. Nothing
retires them, so an account that signs in often accumulates a row of them.
The console's Keys tab shows which one the tab you are looking at is
using, and lets you copy that key or revoke the others. GET /v0/api-keys
lists the same rows with created_at and used_at for telling them apart.
Revoking
curl -X DELETE https://api.carlyemail.com/v0/api-keys/$KEY_ID \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY"
A revoked key stops working immediately but stays in the record as the audit trail of what it did. It drops out of listings.
OAuth for MCP clients
Chat clients that cannot hold a static key authenticate over OAuth 2.1 instead. That path issues a real API key behind the scenes, so an MCP caller runs through exactly the same scope and permission checks as everybody else rather than a second model alongside it. See MCP.