Notion API skill for Claude Code
Notion sync scripts that page, pace and chunk. Gives Claude the Notion details that usually cause silent failures: pinned API versions and the newer data source model, cursor pagination on every list, property write shapes, the 100-block and 2,000-character limits, and Retry-After backoff for 429s. Sync jobs pull incrementally by last_edited_time, detect trashed pages, and test against a duplicated database before touching the real one.
- Name
notion-api- Size
- 220 lines · ≈2,300 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds integrations and sync scripts on the Notion API with the official SDKs - databases and data sources, querying with filters and sorts, creating and updating pages and properties, appending and reading blocks, cursor pagination, the 3-requests-per-second rate limit, payload size limits, incremental sync by last_edited_time, and signed webhooks. Use when reading or writing Notion databases from code, syncing Notion with another system, exporting pages to Markdown, hitting 429 or validation errors, or upgrading to the data source API version. Try asking
- Write a script that syncs our Notion task database into Postgres every 15 minutes
- Export every page under this Notion page to Markdown files
- My Notion integration gets object_not_found for a database I can see - why?
Use it when
- Reading or writing Notion databases and pages from code
- Building a one-way or two-way sync between Notion and another system
- Requests fail with 429, validation_error, or object_not_found
- Upgrading code from database_id queries to the data source API
Skip it when
- You need general retry and backoff design across many APIs - use idempotency-retries instead
- You are converting Markdown into other formats after export - use markdown-publishing instead
How to check the result
Run against a duplicated database with more than 100 rows and a long page body, confirm every row and block arrives, and confirm a second incremental run processes only changed pages.
The common mistake it prevents
Assuming one query returns all rows: list endpoints cap at 100 per call, so syncs silently miss everything past the first page.
A Notion integration is done when it pins an API version, pages through every list with cursors, stays under the rate limit with Retry-After backoff, respects payload limits by chunking, syncs incrementally rather than re-reading everything, and fails loudly when it cannot see a page because the integration was never connected to it.
Ground rules
- Token from env (
NOTION_TOKEN), never in code. Internal integrations use one secret; public integrations use OAuth and store a token per workspace. - An integration only sees pages and databases explicitly shared with it (“Connections”
on the page). A 404
object_not_foundusually means “not shared”, not “deleted”. - Pin the
Notion-Version. Version2025-09-03introduced data sources: a database is a container of one or more data sources, and querying/creating rows targets adata_source_id. Code written for2022-06-28usesdatabase_ideverywhere. Do not mix the two models in one codebase. - Average 3 requests per second per integration. Build the limiter in from the start.
- Store Notion IDs (UUIDs) as the join key with external systems; titles change.
Workflow
- [ ] 1. Check SDK and API version in use; confirm the integration is connected
- [ ] 2. Retrieve the database to learn its data sources and property schema
- [ ] 3. Implement reads with pagination and filters
- [ ] 4. Implement writes with property-shape helpers and chunking
- [ ] 5. Add rate limiting and retry
- [ ] 6. For sync: incremental cursor, deletes, conflict rules
- [ ] 7. Verify on a copy of the database
1. Setup
npm ls @notionhq/client # v5+ targets 2025-09-03 (data sources)
pip show notion-client # Python SDK
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_TOKEN, notionVersion: "2025-09-03" });
Get IDs from URLs: the 32-hex string at the end of a page or database URL. Format with dashes or without; both work.
2. Schema first
const db = await notion.databases.retrieve({ database_id: process.env.NOTION_DB_ID! });
const dataSourceId = db.data_sources[0].id; // one per database in most workspaces
const ds = await notion.dataSources.retrieve({ data_source_id: dataSourceId });
console.log(Object.entries(ds.properties).map(([name, p]) => `${name}: ${p.type}`));
Reference properties by name in code but validate against the retrieved schema at startup; a renamed column should fail fast with a clear message, not write nothing.
3. Reading
Query with filter and pagination:
import { iteratePaginatedAPI, isFullPage } from "@notionhq/client";
for await (const page of iteratePaginatedAPI(notion.dataSources.query, {
data_source_id: dataSourceId,
filter: {
and: [
{ property: "Status", status: { equals: "Ready" } },
{ timestamp: "last_edited_time", last_edited_time: { on_or_after: since } },
],
},
sorts: [{ timestamp: "last_edited_time", direction: "ascending" }],
page_size: 100,
})) {
if (!isFullPage(page)) continue;
handle(page);
}
Read block content (recursive; children are separate calls):
async function readBlocks(blockId: string): Promise<any[]> {
const out = [];
for await (const block of iteratePaginatedAPI(notion.blocks.children.list, { block_id: blockId })) {
out.push(block);
if ("has_children" in block && block.has_children) {
(block as any).children = await readBlocks(block.id);
}
}
return out;
}
Reading rules:
- Every list endpoint is paginated (
has_more,next_cursor,page_sizemax 100). Never assume one call returns everything. - Page objects truncate long relation, rollup, people, and rich-text property values
(around 25 references). Use
pages.properties.retrievefor the full value when it matters. - Rich text is an array of segments with annotations; join
plain_textfor plain strings. - Formula and rollup values are computed at read time and cannot be written.
4. Writing
Create a row in a data source:
await notion.pages.create({
parent: { type: "data_source_id", data_source_id: dataSourceId },
properties: {
Name: { title: [{ text: { content: "Fix login bug" } }] },
Status: { status: { name: "In progress" } },
Tags: { multi_select: [{ name: "auth" }, { name: "web" }] },
Due: { date: { start: "2026-11-01" } },
Estimate: { number: 3 },
"External ID": { rich_text: [{ text: { content: "JIRA-412" } }] },
},
children: [
{ object: "block", type: "paragraph",
paragraph: { rich_text: [{ text: { content: "Repro steps..." } }] } },
],
});
Property value shapes:
| Type | Write shape |
|---|---|
| title / rich_text | [{ text: { content, link? } }] |
| select / status | { name } (status options must already exist) |
| multi_select | [{ name }, ...] (new select options are created on write) |
| date | { start, end?, time_zone? } ISO 8601 |
| number / checkbox / url / email / phone_number | primitive value or null |
| relation | [{ id: "<page id>" }] (target must be shared with the integration) |
| people | [{ id: "<user id>" }] |
| files | external URLs [{ name, external: { url } }] or file upload references |
Limits to chunk around:
- At most 100 blocks per
blocks.children.appendor perchildrenarray on create. - At most 2,000 characters per rich text
text.content; split long text into multiple segments or paragraphs. - At most 100 elements in any array, request payload around 500 KB, and two levels of nesting per request; deeper trees need follow-up append calls.
for (let i = 0; i < blocks.length; i += 100) {
await notion.blocks.children.append({ block_id: pageId, children: blocks.slice(i, i + 100) });
}
- Updating a page:
pages.update({ page_id, properties })replaces only the listed properties. Archiving: setin_trash: true(older versions usedarchived). - Replacing body content means deleting old blocks (
blocks.delete) and appending new ones; there is no single “set page content” call. Prefer appending to a dedicated section to avoid destroying human edits.
5. Rate limiting and errors
| Status / code | Meaning | Action |
|---|---|---|
429 rate_limited | Over the request rate | Wait Retry-After seconds, then retry |
409 conflict_error | Concurrent edit | Retry with backoff |
| 500/502/503/504 | Transient | Exponential backoff with jitter, max ~5 tries |
400 validation_error | Wrong property shape, limit exceeded | Fix the request; never retry |
404 object_not_found | Not shared or wrong ID | Tell the user to connect the integration |
401 unauthorized | Bad or revoked token | Stop; surface reconfiguration |
The JS SDK retries some failures automatically in recent versions; still put a
concurrency limit (1-3 in flight) in front of bulk jobs, e.g. with p-limit, so you do
not depend on 429s for pacing.
6. Sync design
- Incremental pull: store the max
last_edited_timeseen; next run filteron_or_afterthat value minus a small overlap (it is minute-granular), and dedupe by page ID pluslast_edited_time. - Deletes: trashed pages drop out of queries. Detect them with a periodic full ID scan or webhook events, then mark them deleted downstream.
- Two-way sync needs an owner per field. Write an
External IDandLast syncedproperty into Notion and never overwrite fields owned by the other side. - Webhooks: integration webhooks deliver events (page created/updated/deleted, etc.) to
your endpoint. Verify the
X-Notion-Signatureheader (HMAC-SHA256 of the raw body using the verification token issued when the subscription was confirmed) with a constant-time compare before processing. Events are signals: fetch the current page state rather than trusting the payload, and dedupe on event ID.
Gotchas
- Linked databases (views of another database) cannot be queried by ID; use the source database.
- Status property options cannot be created through the API; add them in the UI first.
- Users and emails require the integration to have user-information capabilities; without them, people properties return IDs only.
- Timezones: dates without
time_zoneand without a time are calendar dates; with a time and no offset they are interpreted as UTC. - Notion-hosted file URLs expire after about an hour; download immediately or re-fetch.
- Search (
notion.search) is not a reliable enumeration tool; it is eventually consistent and ranks by relevance. Enumerate via data source queries instead.
Verification
- Run against a duplicated test database shared with the integration, never the live one for first runs. Confirm the user wants writes to the real database before switching.
- Seed more than 100 rows and confirm the job processes all of them (pagination).
- Write a page with a 5,000-character body and 250 blocks; confirm it lands intact (chunking).
- Run the incremental sync twice: the second run processes zero or only changed pages.
- Log request counts per second during a bulk run and confirm no sustained 429s.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.