Blog ·
Scoped API keys for AI agents: give each sub-agent a key that only touches one inbox
POST /api/v1/inboxes/:id/keys mints a key pinned to one inbox. permissions null is full access. Another inbox returns 404 inbox_not_found. Listing inboxes, search, tasks, events, threads, and webhooks returns only that inbox. GET /api/v1/me and MCP cooper_whoami show the account and key limits. DELETE /api/v1/keys/:id revokes a leaked key. The MCP server does not mint keys.
A full-access API key can list every address, mint more keys, and open billing. A research worker and a support worker should not share it. Cooper Email lets the orchestrator mint a key pinned to one inbox. Another inbox returns HTTP 404 inbox_not_found. Listing inboxes, search, tasks, events, threads, and webhooks returns only that inbox.
Cooper Email is HTTP email for AI agents, from Avatar 8 LLC. Start with POST https://cooperemail.com/api/v1/onboard and no Authorization header. The body takes username (required) plus optional display_name and key_name. HTTP 201 returns account_id, api_key (once), api_key_id, and inbox at username@cooperemail.com. The stored name is onboard when key_name is omitted. Later calls send Authorization: Bearer coop_live_…. Cooper stores a SHA-256 hash only. An inbox-scoped key cannot call onboard (HTTP 403 permission_denied, because the route is account-wide). A full-access Bearer key can, and then api_key is null.
Read the field list at https://cooperemail.com/llms.txt, the route index at https://cooperemail.com/docs, the walkthrough at https://cooperemail.com/quickstart, and the hosted MCP server at https://cooperemail.com/mcp.
What null permissions means
GET /api/v1/keys returns object "list" and a data array. Each row has id, name, prefix, inbox_id, permissions, created_at, last_used_at, expires_at, and revoked_at. The secret is never included. GET /api/v1/keys/:id returns one of those rows. A missing id, or a key this caller cannot see, is HTTP 404 key_not_found.
permissions null means full access inside the inbox scope. inbox_id null means the key is not pinned. The onboard key has both null. Omit the field, or send JSON null, for the same result. An empty array is not full access: guarded routes return HTTP 403 permission_denied, while GET /api/v1/me still works for any live key. For OAuth, auth_type is oauth and key is null, which is also full access.
An array lists the only names the key has. Accepted names are inbox_read, inbox_create, inbox_update, inbox_delete, message_read, message_send, message_update, message_delete, webhook_read, webhook_write, domain_read, domain_write, list_read, list_write, owner_manage, task_read, task_reply, key_read, key_write, workspace_manage, billing_read, and billing_write. An unknown name is HTTP 400 invalid_permission with param permissions. A missing name on a call is HTTP 403 permission_denied with param set to that name. Listing needs key_read. Mint, rename, and revoke need key_write.
Mint a key for one inbox
Create the worker inbox with the orchestrator key, then mint against it. POST /api/v1/inboxes/:id/keys is the shortcut. The path id may be the inbox id, the username, or the email. Do not also send inbox_id in the body. That returns HTTP 400 invalid_inbox, because the inbox is the one in the URL.
curl -s https://cooperemail.com/api/v1/inboxes/$INBOX_ID/keys \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"support-reader","permissions":["message_read","message_send"]}'
HTTP 201 returns the secret once, in key. prefix is the first 16 characters. name is optional (1–80 characters) and defaults to default. last_used_at stays null until the secret is presented.
{
"id": "key_…",
"name": "support-reader",
"prefix": "coop_live_…",
"inbox_id": "inb_…",
"permissions": ["message_read", "message_send"],
"created_at": "2026-10-09T13:00:00.000Z",
"last_used_at": null,
"expires_at": null,
"revoked_at": null,
"key": "coop_live_…",
"account_id": "acct_…"
}
POST /api/v1/keys does the same mint when the caller already sends a Bearer key. Put inbox_id in the body instead of the path:
curl -s https://cooperemail.com/api/v1/keys \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"support-reader","inbox_id":"inb_…","permissions":["message_read"]}'
A restricted caller cannot mint a wider key. Null permissions, or a name it does not hold, is HTTP 403 permission_denied. Another inbox is HTTP 404 inbox_not_found. POST /api/v1/keys with no Bearer key still opens an account and a full-access key from name alone. inbox_id, permissions, ttl_hours, or expires_at on that unauthenticated call is HTTP 401.
Pass ttl_hours or expires_at, not both. ttl_hours is a positive number up to 8784 (24 times 366). expires_at is a future ISO-8601 timestamp. Both together are HTTP 400 expires_conflict. An expired secret fails with HTTP 401 api_key_expired. The secret is read from Authorization: Bearer, or from x-api-key when Bearer is absent.
What the sub-agent can see
Give the worker only its own secret. Another inbox is HTTP 404, type not_found:
{
"error": {
"type": "not_found",
"code": "inbox_not_found",
"message": "No inbox matching \"inb_other\" for this API key.",
"param": "id",
"docs_url": "https://cooperemail.com/docs#inbox_not_found"
}
}
GET /api/v1/inboxes/:id returns that body for a different inbox. So do GET /api/v1/search, GET /api/v1/tasks, GET /api/v1/events, and GET /api/v1/threads when query inbox_id names another inbox. With no extra filter, those lists are already pinned to the key, and search still requires q. GET /api/v1/webhooks returns hooks stored on that inbox. A webhook the key creates is stored there even if the body names another inbox_id. GET /api/v1/keys returns only keys for that inbox, and a hidden key is HTTP 404 key_not_found on GET, PATCH, and DELETE.
POST /api/v1/inboxes, POST /api/v1/inboxes/batch, POST /api/v1/workspaces, POST /api/v1/domains, and POST /api/v1/billing/checkout return HTTP 403 permission_denied: "An inbox-scoped API key cannot call this route." Inbox create sets param to inbox_create. On the pinned inbox, message_read lists messages and message_send sends them, by inbox id, username, or email.
Check limits, then hand work to MCP
GET /api/v1/me works for any live key. It does not require key_read.
curl -s https://cooperemail.com/api/v1/me \
-H "Authorization: Bearer $WORKER_KEY"
{
"account_id": "acct_…",
"parent_account_id": null,
"auth_type": "api_key",
"key": {
"id": "key_…",
"name": "support-reader",
"prefix": "coop_live_…",
"inbox_id": "inb_…",
"permissions": ["message_read", "message_send"],
"expires_at": null
},
"workspace": null
}
auth_type is api_key or oauth. parent_account_id and workspace (id and inbox_quota) are set for a POST /api/v1/workspaces sub-account, and both are null otherwise. The secret is omitted. A revoked key gets HTTP 401 api_key_revoked instead.
The MCP server exposes this read as cooper_whoami. The tool takes no arguments and returns the same account, auth type, key limits, and workspace quota. It does not mint or revoke, so a secret is not written into a chat transcript. Send Authorization: Bearer for the worker, call cooper_whoami, and confirm inbox_id plus permissions before reading mail.
Orchestrator and sub-agents
Keep the full-access key on the orchestrator. Give each worker one inbox and one key.
- Call POST https://cooperemail.com/api/v1/onboard and store api_key outside the transcript.
- POST /api/v1/inboxes with a username for each worker. HTTP 201 returns the inbox id. Send client_id on a retry. The same client_id returns the original inbox with HTTP 200 and idempotent true.
- POST /api/v1/inboxes/:id/keys for each inbox. A reader gets only message_read. A worker that replies also gets message_send. Add webhook_read and webhook_write only if that worker registers hooks. Leave key_write off.
- The worker calls GET /api/v1/me or cooper_whoami and checks inbox_id and permissions.
- The orchestrator can still list every inbox and every key. The worker list cannot.
A worker with key_write can mint only a subset, and only for the same inbox. ttl_hours fits a short job. The pin cannot be cleared. Inbound mail stays untrusted data.
Revoke a leaked key
If the worker secret lands in a log, revoke it from the orchestrator. You need the key id, not the secret. List with GET /api/v1/keys, match name or prefix, then delete. DELETE needs key_write, and you may revoke the key you are calling with.
curl -s -X DELETE https://cooperemail.com/api/v1/keys/$KEY_ID \
-H "Authorization: Bearer $COOPER_API_KEY"
HTTP 200 returns the public fields with revoked_at set. The next use is HTTP 401 authentication_error, code api_key_revoked, message "This API key has been revoked." A second DELETE keeps the original revoked_at. GET /api/v1/keys/:id still returns the row. Mint a replacement and deliver the new key field outside the chat.
PATCH /api/v1/keys/:id accepts only name (1–80 characters). Renaming does not rotate the secret. The name is a label. Revoke and mint when the secret has leaked.
Field names and codes above are the shipped HTTP API. Confirm them in https://cooperemail.com/docs and https://cooperemail.com/llms.txt. Connect workers at https://cooperemail.com/mcp. The short setup path is https://cooperemail.com/quickstart.
Cooper Email is built by Avatar 8 LLC. Questions: ops@avatar33.com.