API keys
Create, limit, update and revoke a workspace's API keys, including the per-key guardrails meant for keys an agent holds.
These endpoints manage the workspace's sk_live_ API keys, so provisioning can be automated instead of needing a person in the dashboard. They are meant for people and for deployment tooling: a setup script that issues a capped key to a new agent, or a pipeline that rotates a credential. They are deliberately not in /openapi.json, and the MCP server has no tool for them, not even for listing. An agent should be able to send, read and be limited, not rewrite its own limits.
Every endpoint on this page requires domains:write, the closest thing the API has to an administrative scope, so a key that can only send cannot mint a key that can do more. See Authentication for what each scope allows and Limits for agents for how the guardrails behave on a send.
A credential can hand out at most what it holds itself. Creating or editing a key refuses with 403 forbidden any scope the calling key or OAuth token does not hold, and a caller that carries guardrails cannot create or edit a key into something less restricted than itself: the key must have a daily_send_limit no higher than the caller's, an allowed_recipients list inside the caller's, and requires_approval if the caller has it. The details are under Create an API key and Update an API key. A key also may not edit or revoke itself.
Guardrails
A key can carry three limits. They sit on the credential rather than the workspace and are checked on every message the key sends through POST /v1/emails and POST /v1/emails/batch, before anything is stored, because a workspace quota only trips once the damage is done. OAuth access tokens carry none of them.
| Guardrail | Field | Effect on a send |
|---|---|---|
| Daily send limit | daily_send_limit | A ceiling on recipients per UTC day, counting every address in to, cc and bcc that will actually be mailed (after suppressed and opted-out addresses are dropped), when a send is held for approval, scheduled, or accepted by SES. A send that would cross it is refused with 429 daily_limit. |
| Recipient allowlist | allowed_recipients | Every address in to, cc and bcc must match an entry: a full address (ops@example.com) or a domain (example.com or @example.com). Matching ignores case but is otherwise exact, so a subdomain or a lookalike domain does not match. Anything else is refused with 403 recipient_not_allowed. Checked before the daily limit. |
| Approval hold | requires_approval | Every send is held for a person to approve instead of going out, and returns status: "pending_approval". Applies to scheduled sends too. See Approvals. |
Automations respect the key that starts them: enrolling someone (POST /v1/automations/{id}/enroll) or recording an event (POST /v1/events) is refused with 403 forbidden for a key with requires_approval, and with 403 recipient_not_allowed for an address outside the key's allowlist. The steps an automation sends later do not count against the key's daily limit.
Campaigns respect the key too. A key with requires_approval cannot send, schedule, reschedule or resume a campaign, or decide an A/B test's winner: each answers 403 forbidden. Neither can a key with a recipient allowlist, because an audience cannot be held to one (403 forbidden). A key with a daily limit can start a campaign only when the campaign's recipients fit what is left of the key's limit for the day (otherwise 429 daily_limit), and those recipients count against the limit when the campaign starts. See Campaigns.
A guarded key cannot release mail. Deciding an approval (POST /v1/approvals/{id}) is refused with 403 forbidden when the deciding credential is the key that made the send, or is a key with any guardrail: requires_approval, a non-empty allowed_recipients or a daily_send_limit. An OAuth access token cannot decide approvals either (403 forbidden): approvals are decided by a person in the dashboard, or by an API key without guardrails. See Approvals.
The API key object
The key itself and its hash are never part of the object; the key is returned once, in the create response. Every field is always present: a guardrail the key does not have reads null, [] or false.
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier (UUID). |
name | string | A label for people, 1 to 80 characters. |
last4 | string | The last four characters of the key, to recognise it by. |
scopes | array of string | The scopes the key holds. |
daily_send_limit | integer | null | The daily send limit. null when the key has none. |
allowed_recipients | array of string | The recipient allowlist. [] when the key may mail anyone. |
requires_approval | boolean | true when every send is held for approval. |
created_at | string (ISO 8601) | When the key was created. |
last_used_at | string (ISO 8601) | null | When the key last authenticated a request. null until it has. |
revoked_at | string (ISO 8601) | null | When the key was revoked. null on a working key. |
List API keys
GET /v1/api-keys · requires domains:write
Returns the workspace's keys newest first, cursor-paged, including revoked keys, which have revoked_at set. See Pagination.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Page size, at most 100. See Pagination. |
cursor | string | none | The next_cursor from the previous page. |
Example
Response
200 OK
Create an API key
POST /v1/api-keys · requires domains:write
Mints a key and returns it in key. This is the only time the key is shown: only its SHA-256 hash is stored, so it cannot be read back. Store it before doing anything else with the response. A lost key cannot be recovered; revoke it and create another.
Leaving out a guardrail, passing requires_approval: false, or passing an empty allowed_recipients creates the key without that guardrail. Fields not in the table below are ignored.
The new key is checked against the credential making the request, and refused with 403 forbidden when it would be able to do more:
- Every scope in
scopesmust be one the caller holds. - If the caller has a daily send limit,
daily_send_limitis required and must be no higher than the caller's. - If the caller has a recipient allowlist,
allowed_recipientsis required and every entry must be allowed by the caller's list. An address is allowed when the caller's list has that address or its domain; a domain entry (example.comor@example.com) is allowed only when the caller's list has the same domain. - If the caller has
requires_approval, the new key must haverequires_approval: true.
An OAuth access token carries no guardrails, so only its scopes limit what it can create.
This response is never stored for idempotency, because it holds the only copy of the key. A retry with the same Idempotency-Key after this request has answered mints another key; a retry that arrives while it is still running waits up to 8 seconds for it to finish and then mints another key too, because there is no stored response to return; if it is still running after the wait, the retry answers 409 idempotency_in_progress. If a request times out, list the keys before retrying and revoke any extra one.
Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | none | 1 to 80 characters. |
scopes | array of string | Yes | none | At least one of emails:send, emails:read, domains:read, domains:write, contacts:read, contacts:write, broadcasts:read, broadcasts:write, webhooks:read, webhooks:write, threads:read, templates:read, templates:write. |
daily_send_limit | integer | No | none | A positive integer: the most recipients the key may send to per UTC day. |
allowed_recipients | array of string | No | none | Up to 200 entries, each a full address (ops@example.com) or a domain (example.com or @example.com). An entry that is neither, such as acme, ops@ or a URL, is refused with 422, naming it. |
requires_approval | boolean | No | false | true holds every send from the key for approval. |
Example
Response
201 Created
An API key object with key added.
Errors
| Status | Type | When |
|---|---|---|
| 422 | invalid_request | name is missing, empty or longer than 80 characters; scopes is missing, empty or contains an unknown scope; daily_send_limit is not a positive integer; allowed_recipients has more than 200 entries; or requires_approval is not a boolean. details lists the problems. |
| 422 | invalid_request | An allowed_recipients entry is neither an email address nor a domain. The message starts allowed_recipients: and quotes each such entry, and details gives each one's position, for example ["allowed_recipients", 1]. |
| 403 | forbidden | The key would hold a scope the caller does not, or would be less restricted than the caller's own guardrails. The message says which. |
Update an API key
PATCH /v1/api-keys/{id} · requires domains:write
Changes a key's name, scopes or guardrails in place, so a limit can be tightened without re-issuing the credential and updating everything that holds it. The key itself does not change.
Fields you leave out are unchanged. scopes and allowed_recipients, when given, replace the whole list. To clear a guardrail, send daily_send_limit: null, allowed_recipients: null (or []), or requires_approval: false: the guardrail is removed from the key, and the response reads daily_send_limit: null, allowed_recipients: [] or requires_approval: false.
Unlike create, unknown fields are refused with 422.
A key may not update itself. Raising your own daily limit or lifting your own approval hold would be the guardrail removing itself on the instruction of the thing it guards, so a request whose id is the calling key answers 403 forbidden. Use a different key or the dashboard. A revoked key cannot be updated either, and answers 409 invalid_state.
An update cannot give a key more than the caller holds, by the same rules as Create an API key, applied to the key as it would be after the update:
- A scope the key does not already have must be one the caller holds. Removing scopes is always allowed, and so is keeping ones the caller lacks.
- A guardrail the request sets or clears must stay within the caller's own: with a caller limited to 100 a day,
daily_send_limit: nullanddaily_send_limit: 500are refused and50is accepted. - When the update adds a scope, the whole resulting key must be within the caller's guardrails, even the fields the request does not mention, because gaining a scope is gaining power.
Changing only name, or only removing scopes, is never refused by these rules.
Path parameters
| Parameter | Description |
|---|---|
id | The key's id. |
Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | No | unchanged | 1 to 80 characters. |
scopes | array of string | No | unchanged | At least one scope from the list under Create an API key. Replaces the current scopes. |
daily_send_limit | integer | null | No | unchanged | A positive integer sets the limit; null removes it. |
allowed_recipients | array of string | null | No | unchanged | Up to 200 entries, replacing the current list; null or [] removes the allowlist. Each entry must be a full address or a domain (example.com or @example.com), as on create. |
requires_approval | boolean | No | unchanged | true turns the approval hold on; false removes it. |
Example
Response
200 OK
The API key object as stored after the update.
Errors
Checked in this order: the body, then the allowed_recipients entries, then whether the key is the caller, then whether it exists, then whether it is revoked, then what the update would grant.
| Status | Type | When |
|---|---|---|
| 422 | invalid_request | A field is invalid, or the body has a field not in the table above. details lists the problems. |
| 422 | invalid_request | An allowed_recipients entry is neither an email address nor a domain. The message starts allowed_recipients: and quotes each such entry. Nothing is changed. |
| 403 | forbidden | id is the key making the request. A key cannot edit its own scopes or limits. |
| 404 | not_found | No key with this id in the workspace. |
| 409 | invalid_state | The key is revoked. A revoked key cannot be edited. |
| 403 | forbidden | The update would add a scope the caller does not hold, or leave a guardrail less restrictive than the caller's. The message says which. |
Revoke an API key
DELETE /v1/api-keys/{id} · requires domains:write
Revokes a key. It stops authenticating at once: every request made with it afterwards answers 401. The key stays in the list with revoked_at set, so recent traffic can still be traced to it. Revoking cannot be undone through the API. Revoking a key that is already revoked succeeds again and keeps its original revoked_at.
A key may not revoke itself, because a request that locked out the only key a script holds would leave no way back through the API. Use a different key or the dashboard.
Path parameters
| Parameter | Description |
|---|---|
id | The key's id. |
Example
Response
200 OK
The key object with revoked_at set, the same object the list shows for it from now on. A revoked key is not deleted, so this is the key rather than a deletion result.
Errors
Checked in this order: whether the key exists, then whether it is the caller.
| Status | Type | When |
|---|---|---|
| 404 | not_found | No key with this id in the workspace. |
| 403 | forbidden | id is the key making the request. A key cannot revoke itself. |