docs: reconcile API key permissions with what the API enforces - #194
Merged
Conversation
The published ApiKeyPermissions list had drifted from the server's since thread permissions were consolidated onto the message permissions at the end of March 2026. Reported by a customer who set thread_read and thread_delete to false, saw them come back true, and could not delete a thread with a key granted thread_delete. The API strips unrecognized fields inside the permissions object rather than rejecting them, so a key requesting thread_delete silently received nothing. That made the drift invisible to callers. Definition: - Remove thread_read and thread_delete. Neither exists on the server; thread handlers gate on message_read, message_update, message_delete. - Add message_delete and label_unauthenticated_read, both enforced but never published. message_delete is what actually governs thread deletion, so its absence made the customer's question unanswerable from the reference. Permissions guide: - Replace the Threads permission table with the operation-to-message- permission mapping, plus a warning that no thread permission exists. - Add the missing permissions to the reference tables, including api_key_update, and update the code examples and the Cursor/Claude blocks so no example teaches a permission that does nothing. Websockets guide: - message.received.unauthenticated is gated by label_unauthenticated_read the same way spam and blocked are; the page said only spam and blocked required a permission. The definition and src/core/schemas/api-key.ts now agree on all 36 keys in the same order. Nothing binds them, so this can drift again. Note for the changelog: removing the two fields is breaking for generated SDKs. They never had any effect, so no behavior changes, but setting them explicitly becomes a type error on upgrade. Co-Authored-By: Claude <[email protected]>
📋 View the side-by-side API change reviewThe link expires in 7 days. 🔒 Your API specs are encrypted in CI before they're uploaded. The decryption key stays in this link's URL fragment (after the #), which browsers never send to a server, so oasdiff cannot read your specs. How it works → Posted automatically by the oasdiff GitHub Action. To turn this off (no spec upload, no comment), set |
✨ API Changes# API Changelog n/a vs. n/a
## API Changes
### GET /v0/api-keys
- :warning: removed the optional property `api_keys/items/permissions/thread_delete` from the response with the `200` status
- :warning: removed the optional property `api_keys/items/permissions/thread_read` from the response with the `200` status
- added the optional property `api_keys/items/permissions/label_unauthenticated_read` to the response with the `200` status
- added the optional property `api_keys/items/permissions/message_delete` to the response with the `200` status
### POST /v0/api-keys
- :warning: removed the request property `permissions/thread_delete`
- :warning: removed the request property `permissions/thread_read`
- :warning: removed the optional property `permissions/thread_delete` from the response with the `200` status
- :warning: removed the optional property `permissions/thread_read` from the response with the `200` status
- added the new optional request property `permissions/label_unauthenticated_read`
- added the new optional request property `permissions/message_delete`
- added the optional property `permissions/label_unauthenticated_read` to the response with the `200` status
- added the optional property `permissions/message_delete` to the response with the `200` status
### GET /v0/inboxes/{inbox_id}/api-keys
- :warning: removed the optional property `api_keys/items/permissions/thread_delete` from the response with the `200` status
- :warning: removed the optional property `api_keys/items/permissions/thread_read` from the response with the `200` status
- added the optional property `api_keys/items/permissions/label_unauthenticated_read` to the response with the `200` status
- added the optional property `api_keys/items/permissions/message_delete` to the response with the `200` status
### POST /v0/inboxes/{inbox_id}/api-keys
- :warning: removed the request property `permissions/thread_delete`
- :warning: removed the request property `permissions/thread_read`
- :warning: removed the optional property `permissions/thread_delete` from the response with the `200` status
- :warning: removed the optional property `permissions/thread_read` from the response with the `200` status
- added the new optional request property `permissions/label_unauthenticated_read`
- added the new optional request property `permissions/message_delete`
- added the optional property `permissions/label_unauthenticated_read` to the response with the `200` status
- added the optional property `permissions/message_delete` to the response with the `200` status
### GET /v0/pods/{pod_id}/api-keys
- :warning: removed the optional property `api_keys/items/permissions/thread_delete` from the response with the `200` status
- :warning: removed the optional property `api_keys/items/permissions/thread_read` from the response with the `200` status
- added the optional property `api_keys/items/permissions/label_unauthenticated_read` to the response with the `200` status
- added the optional property `api_keys/items/permissions/message_delete` to the response with the `200` status
### POST /v0/pods/{pod_id}/api-keys
- :warning: removed the request property `permissions/thread_delete`
- :warning: removed the request property `permissions/thread_read`
- :warning: removed the optional property `permissions/thread_delete` from the response with the `200` status
- :warning: removed the optional property `permissions/thread_read` from the response with the `200` status
- added the new optional request property `permissions/label_unauthenticated_read`
- added the new optional request property `permissions/message_delete`
- added the optional property `permissions/label_unauthenticated_read` to the response with the `200` status
- added the optional property `permissions/message_delete` to the response with the `200` status💡 Download |
sidharth0612
approved these changes
Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Customer report (Tanasi Studio, 2026-07-15): they created an inbox-scoped key with
thread_read: falseandthread_delete: falseset explicitly, and both came backtrue. They also found that a control key grantedthread_delete: truecould not delete a thread at all.Both observations are explained by the same thing:
thread_readandthread_deletedo not exist on the server. They were removed at the end of March 2026 (9542d122,0f4790b3) when thread authorization was consolidated onto the message permissions. This repo's definition was never updated, so we have been publishing two permissions the API does not implement for four months.The API strips unrecognized fields inside the
permissionsobject rather than rejecting them, so a key requestingthread_deletesilently receives nothing and still gets a 200. That is what made the drift invisible to callers.The customer's key was never actually at risk — thread deletion requires
message_delete, which they had not granted. Butmessage_deletewas also undocumented, so the question was unanswerable from our reference.What changed
fern/definition/api-keys.ymlthread_readandthread_delete.message_deleteandlabel_unauthenticated_read, both enforced by the server but never published.fern/pages/core-concepts/permissions.mdxapi_key_update.fern/pages/websockets.mdxmessage.received.unauthenticatedis gated bylabel_unauthenticated_readthe same way spam and blocked are; the page claimed only spam and blocked required a permission.Verification
The definition and
agentmail-api/src/core/schemas/api-key.tsnow agree on all 36 keys in the same order, every server permission appears in a reference table, and no phantom permission remains in any example. Checked programmatically against the currentpermissionShape.Removing the two fields drops them from the
ApiKeyPermissionsmodel. They never had any effect, so no behavior changes, but setting them explicitly becomes a type error on upgrade. The reporting customer sets both today. This needs a changelog entry with a breaking-changes section.Not covered here
permissionsobject should reject unknown keys. Flipping it straight to strict would 400 anyone who copy-pasted the old example on this page, which containedthread_read, so it needs a deprecation path.thread_read: truewithoutmessage_read: truehas had a thread-blind key since March. Findable by scanningapi-key-tablefor keys created after 2026-03-30 with apermissionsattribute and nomessage_read.Testing
Not preview-built locally — the
fernCLI is not installed on this machine. The new Threads section adds a<Warning>component; please confirm it renders before merge.🤖 Generated with Claude Code