mcpbeat Sign in

Notion Connector Skill for Cursor

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`).

8k tokens
context cost
the whole folder, loaded on every use
7
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill notion-connector

What comes with it

21 840 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/api-versions.md
references/property-shapes.md
references/sync-patterns.md
scripts/verify.sh

The instruction itself

12 sections, as written by the author

Notion connector — Notion as a code-backed ops store

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.

Route elsewhere

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

Setup (4 steps)

  • Create an internal integration in Notion → Settings → Integrations. Copy

the secret — it looks like ntn_... (older ones secret_...).

  • Put the token in an env var, never in client-side JS, never committed. It

is a bearer secret; treat it like a password. See ../secure-coding/SKILL.md.

  • Share the target database/page with the integration in the Notion UI

(the page ••• menu → Connections). *Skip this and every call 404s or

returns empty* — the integration sees nothing it was not explicitly granted.

  • Construct the SDK client with a pinned Notion-Version. Official JS SDK

is @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
});

The database → data source model (biggest gotcha)

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

Query a data source

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;
}

Property write shapes

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.

Create / update pages (rows)

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).

Sync patterns

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.

  • One-way (app → Notion): upsert on every sync; the app is source of truth.
  • Two-way: track a last_edited_time watermark on each side; last-writer

wins, or flag conflicts for review. Cursor-checkpoint large pulls.

Dedupe, two-way reconcile, and checkpointing → references/sync-patterns.md.

Rate limits & resilience

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;
    }
  }
}

Version migration

| From → To | What changed |

|---|---|

| 2022-06-282025-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-032026-03-11 | block after param → position object (after_block/start/end); archivedin_trash (pages/dbs/blocks/data sources); block type transcriptionmeeting_notes |

Exact field/endpoint diffs → references/api-versions.md.

Anti-patterns

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

verify.sh

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.

How to use it

Copy the folder

Take ericrisco/notion-connector from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

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.