---
title: "API keys"
description: "Read, create, change, rotate and revoke keys, and read what they did."
url: "https://nextmail.sh/docs/api/keys"
area: "API"
category: "Keys & webhook logs"
---

# API keys

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

**Also documents:** `GET /keys`, `POST /keys`, `GET /keys/{id}`, `PATCH /keys/{id}`, `DELETE /keys/{id}`, `POST /keys/{id}/rotate`, `POST /keys/{id}/revoke`, `GET /keys/requests`, `GET /keys/{id}/requests`, `GET /keys/activity`, `GET /keys/stats`, `GET /keys/{id}/activity`

## 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

- [`GET /keys`](https://nextmail.sh/docs/api/reference/api-keys#get-keys): full reference
- [`POST /keys`](https://nextmail.sh/docs/api/reference/api-keys#post-keys): full reference
- [`GET /keys/{id}`](https://nextmail.sh/docs/api/reference/api-keys#get-keys-id): full reference
- [`PATCH /keys/{id}`](https://nextmail.sh/docs/api/reference/api-keys#patch-keys-id): full reference
- [`DELETE /keys/{id}`](https://nextmail.sh/docs/api/reference/api-keys#delete-keys-id): full reference
- [`POST /keys/{id}/rotate`](https://nextmail.sh/docs/api/reference/api-keys#post-keys-id-rotate): full reference
- [`POST /keys/{id}/revoke`](https://nextmail.sh/docs/api/reference/api-keys#post-keys-id-revoke): full reference
- [`GET /keys/requests`](https://nextmail.sh/docs/api/reference/api-keys#get-keys-requests): full reference
- [`GET /keys/{id}/requests`](https://nextmail.sh/docs/api/reference/api-keys#get-keys-id-requests): full reference
- [`GET /keys/activity`](https://nextmail.sh/docs/api/reference/api-keys#get-keys-activity): full reference
- [`GET /keys/stats`](https://nextmail.sh/docs/api/reference/api-keys#get-keys-stats): full reference
- [`GET /keys/{id}/activity`](https://nextmail.sh/docs/api/reference/api-keys#get-keys-id-activity): full reference
