> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cognee.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Notion

> Connect a Notion workspace over OAuth, pick the pages and data sources to cover, and sync them into a dataset of that workspace's own.

Connect a **Notion workspace** to Cognee through Notion's OAuth flow, pick the pages and data sources you want covered, and have them become searchable memory in a dataset of that workspace's own.

> [!NOTE]
>
> 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](https://docs.cognee.ai/integrations/dlt-integration#notion-connector), which this integration runs underneath.

## What It Does

* **One authorization, one workspace.** The Notion workspace id becomes the connection's `providerAccountId` and the workspace name its `accountLabel`. A second workspace is a second connection — see [Multiple workspaces](#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 `/sync` is 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](https://docs.cognee.ai/integrations/dlt-integration.md#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](https://www.notion.so/profile/integrations), create a **public** integration (OAuth) and add `https://<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`):

| Variable | Value |
| - | - |
| `NOTION_CLIENT_ID` | From the public integration's OAuth settings. |
| `NOTION_CLIENT_SECRET` | From the public integration's OAuth settings. Notion's token endpoint takes the ID and secret as HTTP Basic. |
| `NOTION_STATE_SECRET` | Any long random string. It signs the OAuth `state` parameter. |
| `NOTION_REDIRECT_URI` | The callback listed on the integration, e.g. `http://localhost:8000/api/v1/integrations/notion/callback`. |
| `NOTION_FRONTEND_BASE_URL` | Your frontend's origin, e.g. `http://localhost:3000` — where the callback sends the browser when the install finishes. |
| `INTEGRATION_CREDENTIALS_KEY` | Not Notion-specific — the key the integrations framework encrypts stored tokens under, as for the other connectors. Generate one with `openssl rand -base64 32`. `INTEGRATION_CREDENTIALS_KEYS` (a JSON keyring) is the preferred, rotation-capable form. |

`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

```bash theme={null}
# Mint the authorize URL (authenticated as the Cognee user who will own the data)
curl -X POST https://<your-host>/api/v1/integrations/notion/authorize \
  -H "Authorization: Bearer <your-cognee-token>"
# {"authorizeUrl":"https://api.notion.com/v1/oauth/authorize?...&owner=user&state=..."}
```

As with the other OAuth connectors, `/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>`:

| Outcome | Meaning |
| - | - |
| `connected` | The workspace is bound. Nothing is synced yet — see [Choosing what to index](#choosing-what-to-index). |
| `cancelled` | The user cancelled, or Notion rejected the request. Nothing was stored. |
| `error_invalid_state` | The state was missing, tampered with, expired, or the install cookie did not match. |
| `error_already_connected` | This workspace is already connected to a different Cognee user. |
| `error_exchange_failed` | The token exchange failed, or Notion's response carried no `workspace_id`. The traceback is in the backend log. |

## 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:

```bash theme={null}
curl https://<your-host>/api/v1/integrations/notion/resources \
  -H "Authorization: Bearer <your-cognee-token>"
```

The list holds every live page and data source shared with the integration, **except** those whose parent is also shared — the parent's walk already reads them. The response carries them as `resources`, next to the saved `selected` list, and each entry looks like this:

```json theme={null}
{
  "id": "page:1f2e3d4c5b6a79881f2e3d4c5b6a7988",
  "name": "Engineering Wiki",
  "description": null,
  "attributes": {"kind": "page", "parent_kind": "workspace", "parent_id": null},
  "selected": false
}
```

The `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:

```bash theme={null}
curl -X PUT https://<your-host>/api/v1/integrations/notion/resources \
  -H "Authorization: Bearer <your-cognee-token>" \
  -H "Content-Type: application/json" \
  -d '{"resourceIds": ["page:1f2e3d4c5b6a79881f2e3d4c5b6a7988"]}'
```

`/folders` and `/labels` are aliases for `/resources`.

| `resourceIds` | Meaning |
| - | - |
| `null` | Refused with **422** (`notion needs an explicit list of resource ids; null is not supported`). |
| `[]` | Nothing. This is what a new connection holds. |
| `["page:<id>", "data_source:<id>", …]` | Exactly those roots, plus everything nested under them. |

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.

> [!WARNING]
>
> **Narrowing a selection deletes data.** The next sync after you drop a root retires what it put in the dataset, and an empty selection retires everything the workspace has indexed. Retiring happens only after a run in which no root failed, so a transient error never takes data with it.

## Syncing and Status

Connecting does **not** start a sync. Ask for one:

```bash theme={null}
curl -X POST https://<your-host>/api/v1/integrations/notion/sync \
  -H "Authorization: Bearer <your-cognee-token>"
# {"accepted": true}
```

The sync runs in the background. While a run is already going for that workspace the call returns `{"accepted": false}` and starts nothing. Each run:

1. **Checks the token** with `GET /users/me`. If Notion answers `401` / `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.
2. **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.
3. **Runs one `notion_source()` per root** through [`remember()`](https://docs.cognee.ai/python-api/remember) with `write_disposition="merge"` and `self_improvement=False`. How the walk, rendering, deletions and node sets work, and what it costs, is described in the [dlt Notion connector](https://docs.cognee.ai/integrations/dlt-integration.md#notion-connector) section.

Pages land in **one dataset per workspace**, named `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.

```python theme={null}
import cognee

results = await cognee.recall(
    "What is our on-call rotation?",
    datasets=["notion_account_3f2a1b9c4d"],
)
```

Read the outcome from `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:

```bash theme={null}
curl https://<your-host>/api/v1/integrations/notion/connections \
  -H "Authorization: Bearer <your-cognee-token>"
# {"connections": [{"providerAccountId": "<workspace id>", "accountLabel": "Acme", "connectedAt": "..."}, ...]}
```

Connections are listed newest first. Pass a `providerAccountId` as `account_id` to `GET /connection`, `GET` and `PUT /resources`, `POST /sync`, or `DELETE /connection` to act on that workspace:

```bash theme={null}
curl -X POST "https://<your-host>/api/v1/integrations/notion/sync?account_id=<workspace id>" \
  -H "Authorization: Bearer <your-cognee-token>"
```

Without `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_grant` on 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

```bash theme={null}
# Revoke the connection, keep the indexed data
curl -X DELETE "https://<your-host>/api/v1/integrations/notion/connection" \
  -H "Authorization: Bearer <your-cognee-token>"

# Revoke it and forget the workspace's dataset as well
curl -X DELETE "https://<your-host>/api/v1/integrations/notion/connection?delete_data=true" \
  -H "Authorization: Bearer <your-cognee-token>"
```

Add `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()`](https://docs.cognee.ai/python-api/forget) later if needed.

## Limitations

* **No automatic refresh.** No webhooks and no scheduler: `/sync` is 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](https://docs.cognee.ai/integrations/dlt-integration.md#notion-connector).
* **One sync per workspace at a time, per process.** The running-sync guard lives in the API process.

## Troubleshooting

> **`POST /authorize` or the callback returns 503 `notion integration is not configured on this server.`**
>
> On `POST /authorize`, one of `NOTION_CLIENT_ID`, `NOTION_REDIRECT_URI` or `NOTION_STATE_SECRET` is unset; the backend log names it (`NOTION_<NAME> is not configured`). On the callback, `NOTION_FRONTEND_BASE_URL` is unset, so there is nowhere to send the browser.

> **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`.

> **A page I shared is missing from the picker**
>
> Its parent is also shared, so it is covered by selecting the parent. Archived and trashed pages are not listed either.

> **`PUT /resources` returns 422**
>
> `resourceIds` was `null` (Notion needs an explicit list) or contained a blank id.

> **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"`**
>
> 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**
>
> The id is not one of your active connections — it is unknown, disconnected, or belongs to another user. List yours with `GET /connections`.

## Related

> **[dlt Notion connector](https://docs.cognee.ai/integrations/dlt-integration.md#notion-connector)**
>
> Sync Notion from your own script with `notion_source()` and an internal integration token.

> **[Google Drive](https://docs.cognee.ai/integrations/google-drive-integration.md)**
>
> The same OAuth, picker and manual-sync flow for Drive folders.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.