This integration is for self-hosted Cognee deployments — you create and own the Notion public integration, so it talks only to your own Cognee backend. To sync Notion from your own script with an internal integration token instead, use the
notion_source() dlt connector, which this integration runs underneath.What It Does
- One authorization, one workspace. The Notion workspace id becomes the connection’s
providerAccountIdand the workspace name itsaccountLabel. A second workspace is a second connection — see Multiple workspaces. - Nothing syncs until you pick roots. Ingesting a whole workspace costs LLM tokens, so a fresh connection holds an empty selection and “everything” is not a valid selection.
- Manual sync. Notion sends no webhooks on this path, so
/syncis what updates memory. Each run is incremental and forgets pages that were deleted or moved out of a selected root, as described for the dlt Notion connector. - No extra to install. The adapter and
notion_source()both use core dependencies.
Setup
1. Create the Notion public integration
At notion.so/profile/integrations, create a public integration (OAuth) and addhttps://<your-host>/api/v1/integrations/notion/callback as a redirect URI. It must match NOTION_REDIRECT_URI verbatim. Then copy the integration’s OAuth client ID and client secret.
When a user connects, Notion asks them which pages to share with the integration. Only shared pages and data sources show up in the picker.
2. Configure Cognee
Add these to your backend’s.env (the same block is in .env.template):
NOTION_API_KEY belongs to the notion_source() connector and is ignored by this integration.
Every value is checked when it is used, not at boot: a deployment without Notion configured starts normally, and the first call that needs a missing value fails with NOTION_<NAME> is not configured. POST /authorize turns that into a 503 (notion integration is not configured on this server.).
Restart the backend after any .env change.
3. Connect
/authorize sets a short-lived cookie the callback checks alongside the signed state, so the install has to finish in the browser that started it, within 10 minutes.
After the user approves, Notion redirects to /api/v1/integrations/notion/callback, which binds the workspace to the Cognee user and sends the browser to <NOTION_FRONTEND_BASE_URL>/integrations?notion=<outcome>:
Choosing What to Index
A new connection is stored with an empty selection. Reconnecting a workspace keeps the selection it already had. List what is selectable:resources, next to the saved selected list, and each entry looks like this:
id is page:<id> or data_source:<id>, with the Notion id in one canonical form (32 lowercase hex characters, no dashes). A page without a title is listed as Untitled. Then save a selection:
/folders and /labels are aliases for /resources.
A blank id is refused with 422. An entry that is not
page:<id> or data_source:<id> fails the next sync.
The picker reads Notion’s search API, which answers about 3 requests per second. After 100 result pages or 60 seconds the listing fails with 502 (Could not list notion resources) rather than returning a partial list.
Syncing and Status
Connecting does not start a sync. Ask for one:{"accepted": false} and starts nothing. Each run:
- Checks the token with
GET /users/me. If Notion answers401/unauthorized, the connection is revoked locally and the sync stops. Because Notion usually issues tokens with no expiry, this is the only way a workspace that removed the integration in Notion shows up in Cognee. - Drops nested roots. A selected root that sits under another selected root would be read twice, under two node sets. The sync follows each root’s parent chain, one request per level, and drops it if the chain reaches another selected root. This also catches pages the picker could not hide, such as a page inside a toggle or column block of a shared page. If Notion reports an object on the chain as not shared, the root is kept. Any other lookup error stops the sync, so a flaky lookup never changes what is synced.
- Runs one
notion_source()per root throughremember()withwrite_disposition="merge"andself_improvement=False. How the walk, rendering, deletions and node sets work, and what it costs, is described in the dlt Notion connector section.
notion_account_<hash>, where <hash> is the first 10 hex characters of the SHA-256 of the workspace id. The workspace name is not part of the name, so renaming the workspace does not move later syncs to a new dataset.
GET /api/v1/integrations/notion/connection (syncStatus, lastSyncedAt, syncCounts). syncStatus is degraded when any root failed, or when the run stopped before syncing (a failed parent lookup, a malformed selection entry). A failed root is counted under failed_ingestion, or under failed_rate_limit when Notion rate-limited it. Nothing refreshes the dataset on its own, so call /sync again, or from your own scheduler, to pick up changes.
Multiple Workspaces
Each workspace you authorize is its own connection with its own selection and dataset. List them:providerAccountId as account_id to GET /connection, GET and PUT /resources, POST /sync, or DELETE /connection to act on that workspace:
account_id the newest connection answers. An account_id that is not one of your active connections returns 404, never another workspace, and a blank one returns 422. GET /api/v1/integrations/status still shows one connection per provider, the newest.
The /connections route and account_id parameter belong to the generic /api/v1/integrations/{provider}/* routes, so they work the same way for every OAuth provider.
Token Lifecycle
- No expiry, no refresh. When Notion’s token response carries no
expires_in, the token is stored without an expiry and used as is. The token check at the start of each sync is what catches a revoked one. - With an expiry, the access token is refreshed 5 minutes before it expires. Notion rotates the refresh token on every refresh, so refreshes for one workspace run one at a time within a process, and a compare-and-swap on the stored credential covers several processes. If a refresh response carries no new refresh token, the old one is kept.
invalid_granton refresh means the workspace removed Cognee’s access in Notion. The local connection is revoked; reconnect to fix it.- A connection with no refresh token cannot refresh and has to reconnect.
Disconnect
account_id=<workspace id> to disconnect a specific workspace; without it the newest connection is disconnected. The local revoke cuts access. Cognee also asks Notion to revoke both the access token and the refresh token, best-effort, so a network error never blocks a disconnect. Without delete_data=true the notion_account_… dataset stays; use forget() later if needed.
Limitations
- No automatic refresh. No webhooks and no scheduler:
/syncis the only thing that updates memory. - Shared content only. The picker and the sync see only what the workspace shared with the integration.
- A workspace belongs to one Cognee user. Connecting a workspace another user holds ends in
error_already_connected. - The
notion_source()limits apply, including the request cost of re-walking every root on each run and the 10,000-row cap on a data source. See the dlt Notion connector. - One sync per workspace at a time, per process. The running-sync guard lives in the API process.
Troubleshooting
Connected, but the dataset is empty
Connected, but the dataset is empty
Expected until you save a selection and call
/sync. Check GET /resources for what is selected and GET /connection for syncStatus and lastSyncedAt.PUT /resources returns 422
PUT /resources returns 422
resourceIds was null (Notion needs an explicit list) or contained a blank id.The connection stopped being connected
The connection stopped being connected
Notion rejected the token at the start of a sync, or a refresh failed with
invalid_grant. Either way the workspace removed Cognee’s access in Notion. The log line is Notion access for workspace <id> was removed at the provider or ... was revoked at the provider. Reconnect.syncStatus: "degraded"
syncStatus: "degraded"
At least one root failed, or the run stopped before syncing, and deselected roots were not retired in that run. The backend log names the failure; fix it and run
/sync again.account_id returns 404
account_id returns 404
The id is not one of your active connections — it is unknown, disconnected, or belongs to another user. List yours with
GET /connections.