---
name: notion-connector
description: "Use when wiring an app, cron job, or service to Notion as an ops backend over the official HTTP API — pushing rows into a Notion database, mirroring a database into your app, syncing both ways without duplicates, building or reading page block content, or fixing an integration that broke after the 2025-09-03 data-source split. Triggers: 'store our tasks in a Notion database from code', 'use Notion as a CRM/content calendar', 'sync our CRM into Notion nightly without duplicates', 'why does POST /v1/databases/:id/query return 404 now', 'integration broke after a teammate added a second data source', 'guardar registros en una base de datos de Notion desde código', 'desar files a una base de dades de Notion'. NOT a generic call-any-REST-API integration (that is api-connector-builder), NOT receiving/verifying inbound Notion webhook events (that is webhooks)."
tags: [notion, ops-backend, api-integration, databases, sync]
recommends: [api-connector-builder, webhooks, automation-flows, spreadsheet-ops, secure-coding]
origin: risco
---

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

## The one rule

**Pin the `Notion-Version` per request, and resolve the data source before you
query.** Post `2025-09-03` a database is a *container* of one or more data
sources — not a queryable table. Skip either half and you get silent 404s on
code that worked last year.

- Current major version: **`2025-09-03`** (the SDK default). Latest: **`2026-03-11`**.
- Behavior differs across versions; an unpinned client drifts when the default moves.

## When to use / when NOT to use

**Use when:** treating a Notion database as a CRM / task tracker / content
calendar from code; pushing or pulling rows on a schedule; two-way sync that
must not duplicate on re-run; building or reading page block content; migrating
a `2022-06-28` integration that broke when someone added a second data source.

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

1. **Create an internal integration** in Notion → Settings → Integrations. Copy
   the secret — it looks like `ntn_...` (older ones `secret_...`).
2. **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`.
3. **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.
4. **Construct the SDK client with a pinned version.** Official JS SDK is
   `@notionhq/client` v5.12.0+ (latest 5.22.0, 2026-05-19); its default
   `notionVersion` is `2025-09-03`, and it supports `2026-03-11` if you opt in.
   The default and method names below are stable across the whole 5.x line.

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

A database now holds a `data_sources` array; each data source has its **own
schema**. One database can have several. You query and read schema against the
*data source*, not the database.

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

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

```ts
// 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`.

```ts
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:

```ts
// 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.

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