Notion Integration Guide

Technology: notion · Category: docs · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/notion

Insight:

Notion is the docs / PM hub — automate status reports, spec-to-ticket conversion, and knowledge capture via the MCP. Great for turning meeting notes into tracked work, but keep customer PII out of pages that sync to external tools (KDPA). Since the 2025-09-03 API version (SDK v5, @notionhq/client ≥5) a database is now a container of one or more data sources — you query dataSources.query({ data_source_id }), not the removed databases.query, and pages parent onto a data_source_id.

███╗   ██╗ ██████╗ ████████╗██╗ ██████╗ ███╗   ██╗
████╗  ██║██╔═══██╗╚══██╔══╝██║██╔═══██╗████╗  ██║
██╔██╗ ██║██║   ██║   ██║   ██║██║   ██║██╔██╗ ██║
██║╚██╗██║██║   ██║   ██║   ██║██║   ██║██║╚██╗██║
██║ ╚████║╚██████╔╝   ██║   ██║╚██████╔╝██║ ╚████║
╚═╝  ╚═══╝ ╚═════╝    ╚═╝   ╚═╝ ╚═════╝ ╚═╝  ╚═══╝

Notion Integration Guide

Focus: Automating documentation, project management, and knowledge base workflows from Claude Code using the official Notion MCP server and REST API.

Overview

Notion is an all-in-one workspace for docs, databases, wikis, and project management. The official Notion MCP (hosted at https://mcp.notion.com/mcp, or the local @notionhq/notion-mcp-server) lets Claude Code search pages, create and update content, query data sources, manage tasks, and build automated documentation pipelines — turning Notion into a live integration layer for your development workflow.

Data-source model (API 2025-09-03+, SDK v5 / @notionhq/client ≥5). As of the 2025-09-03 API version a database is a container of one or more data sources (each with its own schema and rows). You now query a data source, not a database: notion.dataSources.query({ data_source_id }) — the old notion.databases.query was removed in SDK v5. notion.databases.retrieve({ database_id }) returns the data_sources[] array so you can resolve a data_source_id. The current latest header is Notion-Version: 2026-03-11. See the 2025-09-03 upgrade guide.

Here is the big picture — your integration token unlocks a clean path from Claude Code to your workspace:

flowchart LR
  CC["Claude Code"] --> MCP["Notion MCP<br/>hosted or REST Client"]
  MCP -->|"Bearer NOTION_TOKEN (ntn_...)"| API["Notion API"]
  API --> Pages["Pages and Blocks"]
  API --> DB["Databases<br/>each holds Data Sources<br/>tasks, sprints, docs"]
  API --> Search["Search and Comments"]

Official Documentation

Resource URL
Notion Developers https://developers.notion.com
Notion API Reference https://developers.notion.com/reference
API versioning https://developers.notion.com/reference/versioning
2025-09-03 upgrade guide (data sources) https://developers.notion.com/docs/upgrade-guide-2025-09-03
Notion MCP (hosted + local) https://developers.notion.com/docs/mcp
Notion MCP Server (local repo) https://github.com/makenotion/notion-mcp-server
JavaScript Client (@notionhq/client) https://github.com/makenotion/notion-sdk-js
Integration Guide https://developers.notion.com/docs/getting-started

MCP Server Setup

Notion now runs a hosted, OAuth-authenticated MCP server — no token juggling, no local process. This is the path Notion recommends; the local repo may be sunset.

# Add the hosted server via Claude Code CLI (Streamable HTTP)
claude mcp add --transport http notion https://mcp.notion.com/mcp

The first tool call opens a browser OAuth flow to authorize the workspace. Endpoints: https://mcp.notion.com/mcp (Streamable HTTP, recommended) or https://mcp.notion.com/sse (SSE).

Option B — Local Notion MCP Server (@notionhq/notion-mcp-server, v2.x)

Use a local integration token when you want a scoped internal integration instead of workspace OAuth.

# Add via Claude Code CLI
claude mcp add notion -- npx -y @notionhq/notion-mcp-server

.mcp.json Configuration

NOTION_TOKEN is the current, recommended way to pass the integration token — OPENAPI_MCP_HEADERS still works for advanced cases:

{
  "mcpServers": {
    "notion": {
      "command": "npx",
      "args": ["-y", "@notionhq/notion-mcp-server"],
      "env": {
        "NOTION_TOKEN": "${NOTION_TOKEN}"
      }
    }
  }
}

Create an internal integration and copy the token (prefix ntn_…) at: https://www.notion.so/profile/integrations

Notion-Version in v2.x. The server sources the Notion-Version header per operation from its OpenAPI spec — most tools use 2025-09-03, and the Markdown page tools use 2026-03-11 — so you no longer hard-code a version. If you do set one via OPENAPI_MCP_HEADERS, your value wins for every tool.

Available MCP Tools (v2.x — data-source model)

Tool names are hyphenated operation IDs (the old notion_* names were dropped in v2.0). Key tools:

Tool Description
search Search pages and data sources (filter values are now ["page", "data_source"])
query-data-source Filter and sort rows in a data source (data_source_id) — replaces post-database-query
retrieve-a-data-source Get a data source's schema / properties
create-a-data-source Create a new data source (parent.page_id)
update-a-data-source Update data source properties
retrieve-a-database Get database metadata including its data_sources[] IDs
retrieve-page-markdown Read a page's content as Markdown (needs 2026-03-11)
update-page-markdown Edit a page's content with Markdown (needs 2026-03-11)
move-page Move a page to a different parent
notion-create-file-upload Start a file upload

Page, block, and comment tools remain (create/retrieve/update a page, append block children, retrieve/create a comment). 22 tools total in v2.x.


REST API Integration

You are just a few calls away from a working task flow. Since the 2025-09-03 API version you resolve a data_source_id from the database once, then query/create against the data source:

sequenceDiagram
  participant App as "Your Code"
  participant N as "Notion Client"
  participant API as "Notion API"
  App->>N: "databases.retrieve(database_id)"
  N->>API: "GET database"
  API-->>N: "data_sources[] → data_source_id"
  App->>N: "dataSources.query filter and sort"
  N->>API: "POST data_sources query"
  API-->>N: "task rows"
  App->>N: "pages.create parent data_source_id"
  N->>API: "POST pages"
  API-->>N: "new page id"
  App->>N: "blocks.children.append notes"
  N->>API: "PATCH blocks"
  API-->>N: "updated page"

JavaScript / TypeScript Client

Requires @notionhq/client v5+ (v5.26.0 at last review) — v5 is the data-source model. On v4 and earlier, databases.query({ database_id }) still exists; the code below targets v5.

npm install @notionhq/client
import { Client } from "@notionhq/client";

// SDK v5 sends a current default Notion-Version; pass notionVersion to pin one
// (e.g. "2026-03-11" for the Markdown page endpoints).
const notion = new Client({ auth: process.env.NOTION_TOKEN });

// Resolve the data source id for a database (single-source DBs use [0]).
const db = await notion.databases.retrieve({
  database_id: process.env.NOTION_TASKS_DB_ID!,
});
const dataSourceId = db.data_sources[0].id;

// Search for pages
const search = await notion.search({
  query: "API Design",
  filter: { property: "object", value: "page" },
});

// Query a data source (e.g., task tracker) — replaces the removed databases.query
const tasks = await notion.dataSources.query({
  data_source_id: dataSourceId,
  filter: {
    and: [
      { property: "Status", select: { equals: "In Progress" } },
      { property: "Assignee", people: { contains: "me" } },
    ],
  },
  sorts: [{ property: "Due Date", direction: "ascending" }],
});

// Create a new page (row in a data source)
const newTask = await notion.pages.create({
  parent: { type: "data_source_id", data_source_id: dataSourceId },
  properties: {
    Name: { title: [{ text: { content: "Fix auth middleware" } }] },
    Status: { select: { name: "Todo" } },
    Priority: { select: { name: "High" } },
    "Due Date": { date: { start: "2026-08-20" } },
  },
});

// Append content to a page
await notion.blocks.children.append({
  block_id: newTask.id,
  children: [
    {
      object: "block",
      type: "heading_2",
      heading_2: { rich_text: [{ text: { content: "Implementation Notes" } }] },
    },
    {
      object: "block",
      type: "paragraph",
      paragraph: { rich_text: [{ text: { content: "Use JWT with RS256 signing." } }] },
    },
    {
      object: "block",
      type: "code",
      code: {
        language: "typescript",
        rich_text: [{ text: { content: "const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });" } }],
      },
    },
  ],
});

Python Client

notion-client v3+ (v3.1.0 at last review) mirrors the JS SDK's data-source model — data_sources.query replaces databases.query.

pip install notion-client
from notion_client import Client
import os

notion = Client(auth=os.environ["NOTION_TOKEN"])

# Resolve the data source id, then query it
db = notion.databases.retrieve(database_id=os.environ["NOTION_TASKS_DB_ID"])
data_source_id = db["data_sources"][0]["id"]

results = notion.data_sources.query(
    data_source_id=data_source_id,
    filter={"property": "Status", "select": {"equals": "Done"}},
)

for page in results["results"]:
    title = page["properties"]["Name"]["title"][0]["text"]["content"]
    print(f"Completed: {title}")

Pagination — querying large databases

Every Notion list/query endpoint (dataSources.query, search, blocks.children.list, users.list, comments.list) is paginated. A single call returns at most one page, so a dataSources.query against a 500-row task tracker will silently give you back only the first slice unless you follow the cursor. Skip this and your "all tasks" report quietly drops everyone past row 100.

The response shape is the same across endpoints:

Field Type Meaning
results array The items in the current page
next_cursor string | null Pass as start_cursor to fetch the next page; null when finished
has_more boolean true if another page exists

Request side: send start_cursor to resume from a cursor, and page_size to control items per page (max 100, default 100). Omit start_cursor for the first page.

Here is the cursor loop — keep going while the server says there is more:

flowchart TD
  Start["start_cursor = undefined"] --> Call["dataSources.query<br/>start_cursor · page_size 100"]
  Call --> Collect["append response.results to rows"]
  Collect --> Check{"has_more"}
  Check -->|"true"| Next["start_cursor = next_cursor"]
  Next --> Call
  Check -->|"false"| Done["return all rows"]

Copy-paste TS loop — fetch every row

import { Client } from "@notionhq/client";
import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

async function queryAllRows(dataSourceId: string) {
  const rows: PageObjectResponse[] = [];
  let cursor: string | undefined = undefined; // undefined → first page

  do {
    const response = await notion.dataSources.query({
      data_source_id: dataSourceId,
      filter: { property: "Status", select: { equals: "In Progress" } },
      sorts: [{ property: "Due Date", direction: "ascending" }],
      start_cursor: cursor,
      page_size: 100, // max allowed; fewer round-trips
    });

    rows.push(...(response.results as PageObjectResponse[]));
    cursor = response.next_cursor ?? undefined; // null → stop
  } while (cursor !== undefined);

  return rows;
}

const allTasks = await queryAllRows(process.env.NOTION_DATA_SOURCE_ID!);
console.log(`Fetched ${allTasks.length} tasks across all pages`);

Helpers — let the SDK drive the cursor

The official client ships two pagination helpers so you never touch next_cursor by hand:

import { Client, iteratePaginatedAPI, collectPaginatedAPI } from "@notionhq/client";

const notion = new Client({ auth: process.env.NOTION_TOKEN });
const data_source_id = process.env.NOTION_DATA_SOURCE_ID!;

// Stream one row at a time — memory-efficient for huge data sources
for await (const row of iteratePaginatedAPI(notion.dataSources.query, { data_source_id })) {
  console.log(row.id);
}

// Collect everything into one array — only when the dataset fits in memory
const allRows = await collectPaginatedAPI(notion.dataSources.query, { data_source_id });
console.log(`Fetched ${allRows.length} rows`);

Gotcha — page_size and rate limits. page_size is capped at 100; asking for more is ignored, so a large database always needs multiple round-trips. Notion throttles at roughly 3 requests/second, so a deep paginated pull can trip a 429. Keep page_size: 100 to minimize calls, and add a small delay between pages (await new Promise(r => setTimeout(r, 350))) or honour the Retry-After header on 429s.


Environment Variables

# Required — internal integration token (prefix ntn_… ; older tokens were secret_…)
NOTION_TOKEN=ntn_...                # From notion.so/profile/integrations

# Optional — IDs for commonly used databases/data sources/pages
NOTION_TASKS_DB_ID=...             # Task tracker database ID (container)
NOTION_DATA_SOURCE_ID=...          # Data source ID inside that DB (query/create target)
NOTION_DOCS_PAGE_ID=...            # Your docs root page ID
NOTION_SPRINT_DB_ID=...            # Sprint planning database

Find database/page IDs from the URL: notion.so/[workspace]/[page-id]. Resolve a data_source_id at runtime via databases.retrieve({ database_id }).data_sources[0].id.


Automation Workflows

Claude Code Hook: Auto-document on Feature Completion

.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "node scripts/notion-update-docs.js"
          }
        ]
      }
    ]
  }
}

scripts/notion-update-docs.js:

import { Client } from "@notionhq/client";
import { execFileSync } from "child_process";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

// Get last commit message
const commitMsg = execFileSync("git", ["log", "-1", "--pretty=%B"]).toString().trim();
if (!commitMsg.startsWith("feat:")) process.exit(0);

// Append to changelog page
await notion.blocks.children.append({
  block_id: process.env.NOTION_CHANGELOG_PAGE_ID,
  children: [{
    object: "block",
    type: "bulleted_list_item",
    bulleted_list_item: {
      rich_text: [{
        text: {
          content: `[${new Date().toISOString().slice(0, 10)}] ${commitMsg}`,
        },
      }],
    },
  }],
});

console.log("Changelog updated in Notion");

Slash Command: Create Task in Notion

.claude/commands/notion-task.md:

Create a new task in the Notion task database for: $ARGUMENTS

Use the Notion MCP to create a page (row) in the tasks data source with:
- Name: $ARGUMENTS
- Status: Todo
- Priority: Medium
- Assignee: (leave blank)
- Source: "Claude Code"

Report the URL of the created Notion page.

Usage: /project:notion-task "Refactor the authentication module"

CI/CD: Auto-update Notion Sprint Board

# .github/workflows/notion-update.yml
name: Update Notion on Deploy
on:
  workflow_run:
    workflows: ["Deploy to Production"]
    types: [completed]

jobs:
  update-notion:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm install @notionhq/client   # v5+ (data-source model)
      - name: Update Notion
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_SPRINT_DB_ID: ${{ secrets.NOTION_SPRINT_DB_ID }}
        run: |
          node -e "
          const { Client } = require('@notionhq/client');
          const notion = new Client({ auth: process.env.NOTION_TOKEN });
          (async () => {
            const db = await notion.databases.retrieve({ database_id: process.env.NOTION_SPRINT_DB_ID });
            await notion.pages.create({
              parent: { type: 'data_source_id', data_source_id: db.data_sources[0].id },
              properties: {
                Name: { title: [{ text: { content: 'Deployed: ${{ github.sha }}' } }] },
                Status: { select: { name: 'Released' } },
                Date: { date: { start: new Date().toISOString().slice(0,10) } }
              }
            });
            console.log('Notion updated');
          })();
          "

Common Use Cases

Use Case Approach
Task tracking MCP create-a-page in the task data source
Sprint planning MCP query-data-source + analysis
Auto-changelog Hook on git commit → append block children
Knowledge base search MCP search for internal docs
Meeting notes Create a page with structured template
Release notes PR merge → auto-create Notion page

Troubleshooting

Issue Fix
401 Unauthorized Check NOTION_TOKEN value (prefix ntn_…); ensure integration is valid
Page not found (404) Share the page/database with your integration in Notion UI
databases.query is not a function You're on SDK v5 — use dataSources.query({ data_source_id }); resolve the id via databases.retrieve
body failed validation: parent.database_id Under 2025-09-03+, page parent is { type: "data_source_id", data_source_id }
Properties missing Data source schema must match property names exactly (case-sensitive)
Blocks not rendering Rich text must be an array; use [{ text: { content: "..." } }]
Rate limit (429) Notion allows ~3 req/sec; add await new Promise(r => setTimeout(r, 350)) between calls

Setup tip: For the integration to access a database, open the database in Notion → ... menu → Connections → add your integration (or manage it under the integration's Access tab).

Official docs: