Skip to main content
A public connection asks for nothing in its link. The user picks which pages to share on Notion’s consent screen, and the connection’s capabilities are set where you registered it. Notion’s token endpoint takes JSON and HTTP Basic, where most servers take a form. Register a public Notion integration has the screens.

The server

notion_server.py
uses_scopes=False because the link carries none. token_request_format and client_secret_basic are the JSON body and Basic auth Notion’s token endpoint takes. revocation is how a user is disconnected: Disconnecting.

Connecting a user

notion_users.py

Capabilities

Notion’s permissions are the integration’s capabilities, ticked where you register it and never sent on a request. Each tool declares the ones it uses, spelled as Notion’s console spells them, so scopes_for is the list to tick:
notion_capabilities.py
An integration without the capability gets 403 restricted_resource, which names none, so InsufficientScope reports the tool’s own. A page the user did not share answers 404 instead: that is which pages the integration may see, not what it may do.

Refresh behaviour

Notion’s token response carries a refresh_token and no expires_in, and its docs don’t say how long an access token lasts or what a refresh does to the tokens before it. Measured against a live connection: every refresh rotates the refresh token, and the access token before it stops working about three seconds later. That is why the loader returns the whole grant. A worker holding only its own copy of the refresh token presents one another worker has spent, which Notion refuses once the newer grant has served a call, and a worker that refreshes on its own retires the token the others are still using. For the same reason, more than one process passes refresh_lock too: A lock around the refresh. With no lifetime to go on, the client keeps a token until the API refuses it, and that one call fails before the next refreshes.

Disconnecting

The SubjectProvider the pack is configured with builds the user’s client from your store and revokes the grant, in one call:
notion_disconnect.py
The declaration sends the access token, as JSON with the client’s Basic auth, to v1/oauth/revoke. Measured live, a live token ends the grant: the access token is refused within a second and the refresh token is dead. Notion answers 200 to any token, though: one already revoked, one a refresh has retired, a refresh token, or a string it never issued, none of which end anything. RFC 7009 asks exactly that of a server, so Notion is right by the standard, but it means revoke() returns True either way, and only the token sent decides whether the user was disconnected. OAuth2Client.revoke() sends the access token your store holds, under refresh_lock, so it is the live one. A stale token passed to revoke_token reports True and leaves the grant alive.

Where the rest is