Use when wiring server code or a cron job to Notion as an ops backend over its HTTP API: pushing or mirroring database rows, two-way sync without duplicates, page blocks, or an integration broken by the 2025-09-03 data-source split. NOT generic REST wiring (that is `api-connector-builder`), NOT inbound Notion webhook events (that is `webhooks`).
npx skills add https://github.com/ericrisco/rsc-harness --skill notion-connector
Wire server code to the Notion HTTP API so a database behaves like a small
operational store: push rows, pull rows, sync both ways idempotently, read and
write page blocks. This skill owns the outbound Notion API surface only —
the database/data-source data model, property-type write shapes, and the
rate-limit/pagination discipline that the API forces on you.
| Situation | Route to |
|---|---|
| Generic "call any REST API", nothing Notion-specific | ../api-connector-builder/SKILL.md |
| Receiving + verifying *inbound* Notion webhook events | ../webhooks/SKILL.md |
| Notion is one node in a multi-tool sequence | ../automation-flows/SKILL.md |
| CSV/tabular bulk transforms, column mechanics | ../spreadsheet-ops/SKILL.md |
| Token handling, secret rotation, never-client-side rules | ../secure-coding/SKILL.md |
the secret — it looks like ntn_... (older ones secret_...).
is a bearer secret; treat it like a password. See ../secure-coding/SKILL.md.
(the page ••• menu → Connections). *Skip this and every call 404s or
returns empty* — the integration sees nothing it was not explicitly granted.
Notion-Version. Official JS SDKis @notionhq/client v5.12.0+ (latest 5.22.0, 2026-05-19); its default
notionVersion is the current major 2025-09-03, and it supports the
latest 2026-03-11 if you opt in. The default and method names below are
stable across the whole 5.x line. Behavior differs across versions, so pin it
per client (or per request) — an unpinned client drifts when the default moves.
import { Client } from "@notionhq/client"; // v5.12.0+ (latest 5.22.0)
const notion = new Client({
auth: process.env.NOTION_TOKEN, // ntn_... — env only, never inlined
notionVersion: "2025-09-03", // pin it; do not ride the default
});
Post 2025-09-03 a database is a container of one or more data sources, not
a queryable table: it holds a data_sources array and each data source has its
own schema. Resolve the data source *before* you query — query and read
schema against it, not against the database, or code that worked last year 404s
silently.
| You have… | Do this |
|---|---|
| A database_id | GET /v1/databases/:id → read data_sources[] ({id,name}) → use that id |
| Already a data_source_id | Use it directly for query/schema/pages |
| A DB with >1 data source | Pick the right one by name; never assume index 0 |
Endpoints moved to /v1/data_sources:
- POST /v1/databases/:database_id/query # 2022-06-28 — 404s on 2025-09-03+
+ POST /v1/data_sources/:data_source_id/query # query rows
+ GET /v1/data_sources/:data_source_id # schema (properties)
+ PATCH /v1/data_sources/:data_source_id # update schema / title
// Resolve once, then reuse the data_source_id everywhere downstream.
const db = await notion.databases.retrieve({ database_id: DATABASE_ID });
const dataSourceId = db.data_sources[0].id; // verify by name if >1 exists
Send filter + sorts in the body. Page size maxes at 100; results are
cursor-based. Always loop on has_more + next_cursor or you silently drop
every row past the first 100. Filter operand shapes per property type live in
references/property-shapes.md.
async function queryAll(dataSourceId: string, filter?: object) {
const rows: any[] = [];
let cursor: string | undefined = undefined;
do {
const res = await notion.dataSources.query({
data_source_id: dataSourceId,
filter,
page_size: 100, // hard max
start_cursor: cursor,
});
rows.push(...res.results);
cursor = res.has_more ? res.next_cursor ?? undefined : undefined;
} while (cursor);
return rows;
}
Most write failures (HTTP 400) are a wrong property envelope. Each type has its
own JSON shape. The high-frequency ones:
| Type | Write shape (abridged) |
|---|---|
| title | { title: [{ text: { content } }] } |
| rich_text | { rich_text: [{ text: { content } }] } |
| number | { number: 42 } |
| select | { select: { name } } |
| multi_select | { multi_select: [{ name }] } |
| status | { status: { name } } |
| date | { date: { start, end? } } (ISO 8601) |
| checkbox | { checkbox: true } |
| relation | { relation: [{ id }] } |
| people | { people: [{ id }] } |
| url | { url: "https://…" } |
Full write + read-parse JSON for every type → references/property-shapes.md.
A page's parent is the data source, not the database:
// CREATE a row
await notion.pages.create({
parent: { type: "data_source_id", data_source_id: dataSourceId },
properties: {
Name: { title: [{ text: { content: "Ship invoice export" } }] },
Status: { status: { name: "In progress" } },
ExternalId: { rich_text: [{ text: { content: extId } }] },
},
});
// UPDATE a row: PATCH the page by id; send only changed properties
await notion.pages.update({
page_id,
properties: { Status: { status: { name: "Done" } } },
});
To soft-delete: on 2025-09-03 set { archived: true }; on 2026-03-11 that
field is renamed { in_trash: true }. Match the field to the version you pinned
(see references/api-versions.md).
Idempotency is the whole game. **Store the Notion page_id keyed by your
external id** (a column in your DB, or a rich_text "ExternalId" property in
Notion). An upsert is: query-by-external-key → if a row exists, pages.update;
else pages.create. Never blind-create on a re-run — that is how you get
duplicate rows.
last_edited_time watermark on each side; last-writerwins, or flag conflicts for review. Cursor-checkpoint large pulls.
Dedupe, two-way reconcile, and checkpointing → references/sync-patterns.md.
The integration is capped at ~3 requests/second average. Over-limit calls
return HTTP 429 with a Retry-After header (seconds) — honor it, do not
guess a fixed sleep. Cap concurrency, batch reads, back off on 429.
async function withRetry<T>(fn: () => Promise<T>, tries = 5): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e: any) {
const after = Number(e?.headers?.["retry-after"]);
if (e?.status === 429 && i < tries) {
const wait = Number.isFinite(after) ? after * 1000 : 2 ** i * 500;
await new Promise((r) => setTimeout(r, wait));
continue;
}
throw e;
}
}
}
| From → To | What changed |
|---|---|
| 2022-06-28 → 2025-09-03 | DB is a container; query/schema move to /v1/data_sources; page parent is data_source_id; search filter value "database" → "data_source" |
| 2025-09-03 → 2026-03-11 | block after param → position object (after_block/start/end); archived → in_trash (pages/dbs/blocks/data sources); block type transcription → meeting_notes |
Exact field/endpoint diffs → references/api-versions.md.
| Anti-pattern | Why it bites | Do instead |
|---|---|---|
| Unpinned Notion-Version | Behavior shifts when the default moves | Pin per request/client |
| POST /v1/databases/:id/query on 2025-09-03+ | 404 — that path is gone | Resolve data source → /v1/data_sources/:id/query |
| Forgetting to share the DB with the integration | 404 / empty results, looks like an auth bug | Share in the UI (step 3) |
| No pagination loop | Silently drops every row past 100 | Loop on has_more + next_cursor |
| Ignoring 429 / fixed sleep | Hammers the 3 req/s ceiling, gets banned | Honor Retry-After, exponential backoff |
| Blind pages.create on every sync | Duplicate rows on re-run | Upsert: query-by-external-key first |
| Token in client-side JS or committed | Leaked bearer secret = full workspace access | Env var + secret manager |
| Assuming one DB = one schema | Breaks on multi-data-source DBs | Resolve and select by data-source name |
| Using database_id as a page parent | Rejected on 2025-09-03+ | { type: "data_source_id", data_source_id } |
| archived on 2026-03-11 | Field renamed | Use in_trash for that version |
scripts/verify.sh <file-or-dir> statically lints a connector you (or the agent)
wrote: it flags a missing pinned Notion-Version/notionVersion, a deprecated
databases/:id/query query path, a query without a has_more/next_cursor
loop, and missing 429/Retry-After handling. Read-only; exits 0 on a clean or
empty target. It does not call Notion.
Take ericrisco/notion-connector from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.