what a credential is allowed to touch
Scopes
A scope is one named permission on a credential. Every route needs one, every MCP tool needs the same ones as its REST route (the tools page lists them), and the server checks them on every request.
A write scope includes its read scope. todos:write gives you todos:read, so there is no write-only credential: a key that can add to the inbox can also read it. The apps show it the same way: ticking write ticks read, and read cannot be unticked while write is on.
The list
| scope | grants |
|---|---|
todos:read todos:write |
to-dos |
containers:read containers:write |
areas, projects, subprojects, and the headings inside projects |
inbox:read inbox:write |
capture |
tags:read tags:write |
tags |
notes:read notes:write |
notes and everything on them: folders, places, links to to-dos and to other notes, tags on notes, version history and the export |
attachments:read attachments:write |
attachments. attachments:read also reads file bytes |
filters:read filters:write |
saved filters |
settings:read settings:write |
week start, the two reminder settings, and the shared calendar choices |
sessions:read sessions:write |
keys, grants and sessions |
webhooks:write |
webhooks, including reading them |
tasks:read tasks:write |
umbrellas over to-dos, containers, inbox, tags, notes, attachments, filters and settings, never sessions:* or webhooks:write |
Rules that catch people out
The umbrellas do not imply sessions:* or webhooks:write. tasks:write is broad access to content. Listing a user's credentials, or wiring a channel that sends their activity to a URL, is a different kind of power, so it is asked for by name.
webhooks:write covers its own reads. Listing subscriptions reveals every endpoint the user has connected, so there is no read-only half.
attachments:write creates url and text attachments, uploads files and pictures, tags them, attaches them to more places and reorders them. attachments:read reads file bytes at GET /v1/attachments/{id}/file; inbox:read reads a capture's at GET /v1/inbox/{id}/file. Taking an attachment out of its LAST place makes an inbox item, so that one call needs inbox:write too.
GET /v1/export returns the whole account, so it needs every content read scope (todos, containers, notes, inbox, tags, attachments, filters) and settings:read. tasks:read covers all of them.
Three routes widen instead of refusing. GET /v1/search needs todos:read and containers:read, and then includes note hits only with notes:read, capture rows only with inbox:read and attachments only with attachments:read, because a hit carries their text. GET /v1/trash needs todos:read, and lists trashed notes and note folders only with notes:read, tags only with tags:read and filters only with filters:read. GET /v1/todos?group_by=heading includes the project's headings (their names) only with containers:read. So a credential scoped to to-dos still gets its trash and its grouped to-dos: asking what is in your trash should not fail because of a resource you never asked about.
Headings are containers:*. Reading, creating, renaming and reordering a heading needs containers:read or containers:write. Deleting one and making it a subproject also need todos:write, because they rewrite its to-dos; completing a heading's to-dos needs todos:write only.
DELETE /v1/trash needs notes:write as well when the trash holds a note or a note folder, tags:write when it holds a tag and filters:write when it holds a filter, because emptying destroys them.
A granted scope can change later
The user can edit a key's or a connected app's permissions from the app, at any time, without the credential itself changing. Your token keeps working; what it is allowed to do does not.
The credential's stored permissions are what the server checks:
- on REST, read on every request and cached for at most 60 seconds, so a change applies within a minute;
- on MCP, read when a connection opens, so a new connection gets the change at once, while a connection that is already open keeps the tools it opened with until it reconnects;
- on a token refresh, read at once: the refreshed token's
scopeis the current list.
The scopes sealed into your token when it was issued record the moment it was granted and are not consulted.
In practice: treat forbidden_scope as a live answer, not a startup check. A call that worked an hour ago can be refused now, and the fix is the same as it has always been: send the user through approval again, or ask them to tick the box.
GET /v1/me always reports the current list.
You may get less than you ask for
At approval time the user can grant a subset. Ask for todos:read todos:write and they may grant read only.
Read the granted scope from the token response, on the first token and on every refresh. Do not assume you got what you asked for.
{
"access_token": "...",
"token_type": "Bearer",
"scope": "todos:read",
"expires_in": 3600
}GET /v1/me reports the same list at any time.
When a scope is missing
The route returns forbidden_scope. The credential is valid, so refreshing will not help. Send the user through approval again, asking for the scope you need.
Next
- Authentication for how a credential is created.
- Encryption for what a granted scope actually hands over.