← Back to dashboard

Notion Integration Guide

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

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:

Official Documentation


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.

Bash
# 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.

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

JSON
{
  "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:

ToolDescription
searchSearch pages and data sources (filter values are now ["page", "data_source"])
query-data-sourceFilter and sort rows in a data source (data_source_id) — replaces post-database-query
retrieve-a-data-sourceGet a data source's schema / properties
create-a-data-sourceCreate a new data source (parent.page_id)
update-a-data-sourceUpdate data source properties
retrieve-a-databaseGet database metadata including its data_sources[] IDs
retrieve-page-markdownRead a page's content as Markdown (needs 2026-03-11)
update-page-markdownEdit a page's content with Markdown (needs 2026-03-11)
move-pageMove a page to a different parent
notion-create-file-uploadStart 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:

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.

Bash
npm install @notionhq/client
TypeScript
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.

Bash
pip install notion-client
Python
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:

FieldTypeMeaning
resultsarrayThe items in the current page
next_cursorstring | nullPass as start_cursor to fetch the next page; null when finished
has_morebooleantrue 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:

Copy-paste TS loop — fetch every row

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

TypeScript
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

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

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

scripts/notion-update-docs.js:

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

Markdown
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

YAML
# .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 CaseApproach
Task trackingMCP create-a-page in the task data source
Sprint planningMCP query-data-source + analysis
Auto-changelogHook on git commit → append block children
Knowledge base searchMCP search for internal docs
Meeting notesCreate a page with structured template
Release notesPR merge → auto-create Notion page

Troubleshooting

IssueFix
401 UnauthorizedCheck 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 functionYou're on SDK v5 — use dataSources.query({ data_source_id }); resolve the id via databases.retrieve
body failed validation: parent.database_idUnder 2025-09-03+, page parent is { type: "data_source_id", data_source_id }
Properties missingData source schema must match property names exactly (case-sensitive)
Blocks not renderingRich 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).