4 routes
/keys
4 routes.
Personal access tokens: long-lived credentials a user mints for their own scripts. Creating one needs a first-party app session; listing and revoking need only sessions:*.
GET /v1/keys
List personal access tokens
Never the secrets, only the last four characters of each (tokenPrefix), so a user can tell two keys apart. Ordered (createdAt desc, id). A key whose session has ended (signed out elsewhere, say) is not listed.
Scopes: sessions:read
Query
| parameter | type | notes | |
|---|---|---|---|
limit |
integer | optional | min 1, max 200 |
cursor |
string | optional | max 4096 chars |
Response
- 200 AccessKeyList. One page of access keys.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * validation_failed * rate_limited * internal
POST /v1/keys
Create a personal access token (app session only)
Callable only with a first-party app session. An access key or a connected app is refused with forbidden_scope (403, not 401: the credential is valid and the KIND is insufficient, and a 401 would send an SDK into a retry loop against a permanent refusal). This is not only a policy: a key has to carry the user’s master key, and the six hand-off members below are how a signed-in device passes it over. A credential that holds none cannot produce them, so a key genuinely cannot mint a key, which also means a leaked key cannot widen its own scopes or extend its own expiry. Limits: 25 active keys (conflict) and 10 creations a day (rate_limited).
Scopes: any valid credential First-party only. Not callable with an access key or an OAuth token, whatever scopes it holds.
Request body
| field | type | notes | |
|---|---|---|---|
name |
string | required | max 64 chars, min 1. What the user will see in the key list. Encrypted at rest. |
scopes |
array of todos:read, todos:write, containers:read, containers:write, inbox:read, inbox:write, tags:read, tags:write, filters:read, filters:write, attachments:read, attachments:write, notes:read, notes:write, settings:read, settings:write, webhooks:write, sessions:read, sessions:write, tasks:read, tasks:write |
required | The scopes this key carries. At least one; tasks:read/tasks:write are umbrellas that do NOT include sessions:* or webhooks:write. |
expiresInDays |
30, 90, 180, 365 |
optional | MANDATORY expiry, one of 30 / 90 / 180 / 365 days. There is no “never”: a credential nobody revisits is one nobody revokes. |
kid |
string | required | min 1. Hand-off: the server key id from GET /oauth/grant-key. |
nonce |
string | required | min 1. Hand-off: the single-use nonce from the same offer. |
ephemeralPublicKey |
string | required | min 1. Hand-off: base64url P-256 public key the client generated for this one exchange. |
wrappedKey |
string | required | min 1. Hand-off: the master key sealed to the server offer. |
confirm |
string | required | min 1. Hand-off: the confirmation tag; a mismatch is refused. |
keyGen |
integer | required | min 1, max 9007199254740991. Hand-off: the master-key generation being handed over. |
Response
- 201 AccessKeyCreated. The key row PLUS
key, the secret, returned exactly once and never again.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * conflict * payload_too_large * unsupported_media_type * validation_failed * rate_limited * internal
PATCH /v1/keys/{id}
Change an access key’s permissions (app session only)
REPLACES the key’s scopes with the set you send, without re-minting it: the secret is never re-shown, so its permissions have to change underneath a string the user has already pasted somewhere. Callable only with a first-party app session: an access key or a connected app is refused with forbidden_scope (403), so a leaked key cannot widen anything, its own scopes least of all. The new set is authoritative within a minute on REST (the credential’s row is read per request and cached for at most 60 seconds), at once for a new MCP connection and for a token refresh. A revoked key, and an id that belongs to a connected app rather than a key, are both 404.
Scopes: any valid credential First-party only. Not callable with an access key or an OAuth token, whatever scopes it holds.
Request body
| field | type | notes | |
|---|---|---|---|
scopes |
array of todos:read, todos:write, containers:read, containers:write, inbox:read, inbox:write, tags:read, tags:write, filters:read, filters:write, attachments:read, attachments:write, notes:read, notes:write, settings:read, settings:write, webhooks:write, sessions:read, sessions:write, tasks:read, tasks:write |
required | The scopes this credential carries from now on. REPLACES the whole set; at least one. tasks:read/tasks:write are umbrellas that do NOT include sessions:* or webhooks:write. The change reaches REST within a minute (the row is read per request and cached ≤ 60 s) and an MCP connection at its next connect. |
Response
- 200 AccessKey. The key row with its new scopes. Never the secret.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * payload_too_large * unsupported_media_type * validation_failed * rate_limited * internal
DELETE /v1/keys/{id}
Revoke a personal access token
Ends the key’s own session and stamps revokedAt. Revoking a key never signs the user’s own device out. Already revoked is 204 too, so a retry is not a failure.
Scopes: sessions:write
Response
- 204 No body. Revoked.
Errors: grant_expired * grant_revoked * unauthorized * forbidden_scope * not_found * rate_limited * internal