Notion Integration Guide
███╗ ██╗ ██████╗ ████████╗██╗ ██████╗ ███╗ ██╗
████╗ ██║██╔═══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║
██╔██╗ ██║██║ ██║ ██║ ██║██║ ██║██╔██╗ ██║
██║╚██╗██║██║ ██║ ██║ ██║██║ ██║██║╚██╗██║
██║ ╚████║╚██████╔╝ ██║ ██║╚██████╔╝██║ ╚████║
╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝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 the2025-09-03API 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 oldnotion.databases.querywas removed in SDK v5.notion.databases.retrieve({ database_id })returns thedata_sources[]array so you can resolve adata_source_id. The current latest header isNotion-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
| 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
Option A — Hosted Notion MCP (recommended)
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/mcpThe 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-Versionheader per operation from its OpenAPI spec — most tools use2025-09-03, and the Markdown page tools use2026-03-11— so you no longer hard-code a version. If you do set one viaOPENAPI_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:
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/clientimport { 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-clientfrom 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:
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_sizeis 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 a429. Keeppage_size: 100to minimize calls, and add a small delay between pages (await new Promise(r => setTimeout(r, 350))) or honour theRetry-Afterheader 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 databaseFind 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).