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

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.

~/.claude/skills/notion-api/SKILL.md

--- name: notion-api description: Builds integrations and sync scripts on the Notion API with the official SDKs - databases and data sources, querying with filters and… license: MIT ---

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_found usually means “not shared”, not “deleted”.
  • Pin the Notion-Version. Version 2025-09-03 introduced data sources: a database is a container of one or more data sources, and querying/creating rows targets a data_source_id. Code written for 2022-06-28 uses database_id everywhere. 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_size max 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.retrieve for the full value when it matters.
  • Rich text is an array of segments with annotations; join plain_text for 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:

TypeWrite 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_numberprimitive value or null
relation[{ id: "<page id>" }] (target must be shared with the integration)
people[{ id: "<user id>" }]
filesexternal URLs [{ name, external: { url } }] or file upload references

Limits to chunk around:

  • At most 100 blocks per blocks.children.append or per children array 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: set in_trash: true (older versions used archived).
  • 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 / codeMeaningAction
429 rate_limitedOver the request rateWait Retry-After seconds, then retry
409 conflict_errorConcurrent editRetry with backoff
500/502/503/504TransientExponential backoff with jitter, max ~5 tries
400 validation_errorWrong property shape, limit exceededFix the request; never retry
404 object_not_foundNot shared or wrong IDTell the user to connect the integration
401 unauthorizedBad or revoked tokenStop; 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_time seen; next run filter on_or_after that value minus a small overlap (it is minute-granular), and dedupe by page ID plus last_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 ID and Last synced property 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-Signature header (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_zone and 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

  1. 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.
  2. Seed more than 100 rows and confirm the job processes all of them (pagination).
  3. Write a page with a 5,000-character body and 250 blocks; confirm it lands intact (chunking).
  4. Run the incremental sync twice: the second run processes zero or only changed pages.
  5. 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.

esc