Skip to the documentation
API

API keys

Read, create, change, rotate and revoke keys, and read what they did.

GET/keys

Runs any of 12 calls on your workspace.

Reading keys

GET /keys lists every key the caller can see, newest first and a page at a time, with its status, scopes, role, send scope, when it was last used and who made and last changed it. GET /keys/{id} reads one. No read ever returns a secret: maskedKey is enough to tell two keys apart. Both need keys:read.

GET /keys/4c1b257a66287fd113bd89d0
{  "object": "api_key",  "id": "4c1b257a66287fd113bd89d0",  "name": "Billing sender",  "maskedKey": "oe_live_4c1b…kX7a",  "status": "active",  "scopes": ["emails:send"],  "roleId": null,  "domainAllowlist": ["billing.acme.com"],  "expiresAt": "2026-12-22T09:00:00.000Z",  "lastUsedAt": "2026-09-23T08:14:02.000Z",  "createdBy": { "kind": "apiKey", "name": "API key Provisioner", "label": "API key Provisioner" }}

Creating and changing keys

  • POST /keys mints a key and returns its secret in token, once. Left out, the scopes are emails:send and the role, send scope and expiry are the caller’s own.
  • PATCH /keys/{id} renames a key, replaces its scopes or its send scope, and switches it off and on with enabled. Switching off is the reversible choice: the key keeps everything and is refused with inactive_api_key until it is switched on again.
  • POST /keys/{id}/rotate gives a key a new secret and returns it once. The old secret stops working the instant the call returns.
  • POST /keys/{id}/revoke retires a key for good, with an optional reason. DELETE /keys/{id} then removes it from the list and keeps its history.
  • Every one of them needs keys:manage. Rotating the calling key also works with keys:write, exactly like POST /keys/self/rotate.

Never wider than the caller

Every change is checked against the key making it. A key that would end up outside the caller on any axis is refused with 403 beyond_caller_authority, and param names the axis:

  • Scopes: only scopes the caller holds after its own role has narrowed them.
  • Role: a caller capped by a role can only create and manage keys capped by the same role.
  • Expiry: a caller that expires can only create and manage keys that expire no later.
  • Mode: a test key can only reach test keys.
  • Send scope: only domains and addresses inside the caller’s own, and holding one address never covers its whole domain.

A key narrowed to some domains or addresses only sees the keys whose send scope sits inside its own, so any other key is a 404. Over OAuth only the owner of the workspace reaches these calls, and a member’s token is refused with owner_only.

Before you grant keys:manage

The console asks you to verify yourself again before it mints or rotates a key. A call made with a key cannot be asked that, so keys:manage is a credential that makes credentials: a leaked key holding it can mint keys of its own, up to its own reach, that keep working after it is revoked.

  • Give keys:manage only to automation whose job is provisioning keys, never to a key that sends mail.
  • Narrow that key: a role, a send scope and an expiry. Everything it creates inherits all three and can never exceed them.
  • Watch GET /keys/activity. Every key it creates, changes or revokes is recorded against it by name, so a leak shows up as keys you did not expect.
  • keys:read exposes the request log, IP addresses and user agents included. Treat it as audit access.

Request log and activity

GET /keys/requests and GET /keys/{id}/requests read every authenticated call a key made, newest first: method, path, status, error code, duration, IP and user agent, never a body or a query string. keyIds, failedOnly, status, path, since and until narrow it, and they combine. GET /keys/activity and GET /keys/{id}/activity read what happened to keys, with actor naming who did it, as @username or API key <name>. Nothing is pruned, and a deleted key keeps its history.

status takes one or more exact status codes, comma-separated, such as status=401,403. path takes a route such as /emails/:id and matches it whatever the method. It matches the route, not the exact path: an id in the path counts as :id and an email address as :address, so a real id reads its whole route. End it with * to read every route that starts with it, such as /emails/*.

A key reads its own log with no scope at all. GET /keys/self/requests, GET /keys/self/activity and GET /keys/self/stats answer in the same shapes for the key making the call and no other, so a key that can only send can still watch itself. sends in the stats is null unless the key holds emails:read, and an OAuth access token is refused with api_key_only.

Stats

GET /keys/stats reads what the keys did inside a window, the Analytics tab of the API keys page: the mail they sent and what became of it, the calls refused for a bad, revoked or expired secret, the requests they made and how many failed, the routes they called most with the median time each took, and the status codes they got back. It needs keys:read and emails:read. keyIds narrows it to some keys, since and until set the window, 30 days by default, and grain and offsetMinutes shape the series.

GET /keys/stats?keyIds=4c1b257a66287fd113bd89d0&grain=day
{  "object": "api_key_stats",  "since": "2026-09-02T00:00:00.000Z",  "until": "2026-10-02T00:00:00.000Z",  "grain": "day",  "keyIds": ["4c1b257a66287fd113bd89d0"],  "sends": { "totals": { "sends": 412, "delivered": 405, "bounced": 3, "complained": 0, "failed": 4 } },  "rejected": [{ "bucket": "2026-09-28", "count": 2 }],  "requests": [{ "bucket": "2026-09-28", "count": 96, "failed": 1 }],  "routes": [{ "label": "POST /emails", "count": 412, "failed": 4, "medianMs": 180 }],  "codes": [{ "label": "200", "count": 408 }, { "label": "422", "count": 4 }]}

A key narrowed to some domains or addresses counts only the keys whose send scope sits inside its own. A bucket with nothing in it has no entry, so a chart has to fill the gaps.

Reference