Notion API skill for Codex

Sync with Notion databases without 404 surprises. Codex works with Notion the current way: it resolves a database to its data source, reads the schema before writing typed properties, pages every list call, chunks block appends to the 100-block and two-level limits, and paces bulk jobs under the rate limit. It also knows the classic traps, from unshared pages that look like 404s to expiring file URLs, and can migrate older integrations to API version 2025-09-03.

  • Integrations
  • $notion-api
  • 191 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- notion-api

Installs to ~/.agents/skills/notion-api/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $notion-api, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds Notion API integrations with @notionhq/client v5 and API version 2025-09-03 - databases and their data sources, querying with filters and sorts, creating pages with typed properties, appending and reading block trees, rich text limits, pagination, rate limits, file uploads and webhooks. Use when asked to sync data into or out of Notion, read a Notion database from code, generate pages or docs in Notion, migrate an older integration to data sources, or debug "object not found" and validation errors.

Use it when

  • You need to read from or write to a Notion database from code
  • Pages or docs should be generated in Notion with real block content
  • An integration must move to Notion-Version 2025-09-03 and @notionhq/client v5
  • A Notion call returns object_not_found or validation_error and you need to know why

Use something else when

  • The data lives in Airtable bases instead: use airtable-api
  • You want a CMS for a public website rather than an internal wiki: use headless-cms

How to check the result

Re-query the data source with the same filter and match the row count against the source system, then open one created page to check properties and block rendering.

The mistake it prevents

Treating object_not_found as a bad ID when the page was simply never shared with the integration under Connections.

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

--- name: notion-api description: Builds Notion API integrations with @notionhq/client v5 and API version 2025-09-03 - databases and their data sources, querying with filters and… license: MIT ---

Done well means: the integration is pinned to an explicit Notion-Version, reads schema before writing properties, pages every list call to the end, stays under the rate limit, and nothing fails because a page was never shared with the integration.

Setup facts that cause most failures

  • An internal integration token only sees pages and databases explicitly shared with it (page menu -> Connections -> add the integration). Children inherit access. “Could not find database” almost always means “not shared”, not a wrong ID.
  • Public integrations use OAuth; each workspace grants its own token and page selection.
  • IDs are UUIDs; the 32-hex tail of a Notion URL is the page or database ID.
  • Since 2025-09-03, a database is a container that holds one or more data sources. Rows live in a data source. Queries, schema and page parents use data_source_id.
  • Token in env (NOTION_TOKEN); read AGENTS.md for the repo’s secret handling. Calls need network; in an offline Codex sandbox, write the code and ask before running it.

Client setup

import { Client, isFullPage, iteratePaginatedAPI } from "@notionhq/client";

export const notion = new Client({
  auth: process.env.NOTION_TOKEN,
  notionVersion: "2025-09-03",
});

@notionhq/client v5 is the line that speaks data sources (notion.dataSources.*). If the repo is on v2-v4 with databases.query, see “Migrating” below before adding features.

Resolve the data source, then read its schema

const db = await notion.databases.retrieve({ database_id: DB_ID });
// db.data_sources: [{ id, name }]; most databases have exactly one
const dsId = db.data_sources[0].id;

const ds = await notion.dataSources.retrieve({ data_source_id: dsId });
for (const [name, prop] of Object.entries(ds.properties)) {
  console.log(name, prop.type, prop.id);   // write by name, but log IDs for stability
}

If a database has several data sources, ask the user which one; never pick by index silently.

Query rows

const rows = [];
for await (const page of iteratePaginatedAPI(notion.dataSources.query, {
  data_source_id: dsId,
  filter: {
    and: [
      { property: "Status", status: { equals: "In progress" } },
      { property: "Due", date: { on_or_before: "2026-11-30" } },
    ],
  },
  sorts: [{ property: "Due", direction: "ascending" }],
  page_size: 100,
})) {
  if (isFullPage(page)) rows.push(page);
}
Property typeFilter key exampleWrite value example
title{ title: { contains: "x" } }{ title: [{ text: { content: "Name" } }] }
rich_text{ rich_text: { is_not_empty: true } }{ rich_text: [{ text: { content: "..." } }] }
number{ number: { greater_than: 5 } }{ number: 42 }
select{ select: { equals: "Bug" } }{ select: { name: "Bug" } }
multi_select{ multi_select: { contains: "api" } }{ multi_select: [{ name: "api" }] }
status{ status: { equals: "Done" } }{ status: { name: "Done" } }
date{ date: { past_week: {} } }{ date: { start: "2026-11-01", end: null } }
checkbox{ checkbox: { equals: true } }{ checkbox: true }
people{ people: { contains: USER_ID } }{ people: [{ id: USER_ID }] }
relation{ relation: { contains: PAGE_ID } }{ relation: [{ id: PAGE_ID }] }
url / email{ url: { is_empty: true } }{ url: "https://..." }

Formula, rollup, created_time and last_edited_* are read-only. Select options that do not exist are created on write, which pollutes the schema with typos; validate against ds.properties[name].select.options first.

Create and update pages

const page = await notion.pages.create({
  parent: { type: "data_source_id", data_source_id: dsId },
  properties: {
    Name: { title: [{ text: { content: "Q4 launch checklist" } }] },
    Status: { status: { name: "Not started" } },
    Tags: { multi_select: [{ name: "launch" }] },
  },
  children: [
    { object: "block", type: "heading_2", heading_2: { rich_text: [{ text: { content: "Steps" } }] } },
    { object: "block", type: "to_do", to_do: { rich_text: [{ text: { content: "Freeze scope" } }], checked: false } },
  ],
});

await notion.pages.update({ page_id: page.id, properties: { Status: { status: { name: "Done" } } } });

Upserts: Notion has no native upsert. Store your external key in a property (e.g. External ID rich text), query by it, then create or update. Keep a local map to avoid one query per row.

Blocks

LimitValueHandle by
Children per append request100 blocksChunk the list
Nesting per request2 levelsAppend deeper children in follow-up calls using returned block IDs
Text per rich text object2,000 charactersSplit long strings into several rich text items
Payload sizeabout 500 KBChunk
async function appendAll(parentId: string, blocks: any[]) {
  for (let i = 0; i < blocks.length; i += 100) {
    await notion.blocks.children.append({ block_id: parentId, children: blocks.slice(i, i + 100) });
  }
}

async function readTree(blockId: string): Promise<any[]> {
  const out = [];
  for await (const b of iteratePaginatedAPI(notion.blocks.children.list, { block_id: blockId })) {
    out.push(b);
    if ("has_children" in b && b.has_children) (b as any).children = await readTree(b.id);
  }
  return out;
}

Reading a large page recursively is many calls; cache results and respect the rate limit. Editing a block’s content uses blocks.update; reordering is not supported, so rewrites mean delete and re-append (confirm with the user before deleting content).

Rate limits and retries

  • Average 3 requests per second per integration, with short bursts allowed. 429 returns Retry-After (seconds); the SDK retries some errors, but add a queue for bulk jobs.
  • Run bulk writes through a concurrency-1 or concurrency-2 queue with ~350 ms spacing.
  • 409 conflict_error happens on concurrent edits of the same page; retry after a short wait.
  • Property values with more than 25 references (people, relations, rollups) are truncated in page responses; fetch them with notion.pages.properties.retrieve and paginate.

Files

Externally hosted files can be set by URL. To upload, use the File Upload API (notion.fileUploads.create, then send the bytes, then reference the upload ID in a file, image or files property). Files Notion hosts come back as signed URLs that expire after about an hour: download right away, never store the URL as a permanent link.

Webhooks

Integration webhooks deliver events such as page.created, page.properties_updated, data_source.content_updated, comment.created. On setup Notion POSTs a verification_token you paste back in the integration settings. Verify each delivery’s X-Notion-Signature (HMAC-SHA256 of the raw body with that token) and compare in constant time. Events are signals, not full state: re-fetch the page before acting, and dedupe by event id.

Migrating to 2025-09-03

- [ ] Upgrade @notionhq/client to v5 and set notionVersion "2025-09-03"
- [ ] databases.query -> dataSources.query (with data_source_id)
- [ ] Schema reads/updates (properties) -> dataSources.retrieve / dataSources.update
- [ ] Page parent { database_id } -> { type: "data_source_id", data_source_id }
- [ ] Search filter { value: "database" } -> { value: "data_source" }
- [ ] Webhook handlers accept data_source.* event names before switching the subscription version
- [ ] Store data_source_id next to database_id in config

Gotchas

  • archived was renamed to in_trash; trashed pages still return from retrieve.
  • Search (notion.search) is title-only and eventually consistent; do not use it as an index.
  • Rollups and formulas compute on Notion’s side and can lag right after a write.
  • Linked databases (views of another database) cannot be queried directly; use the source.
  • Dates without a time are all-day; with a time, include an offset or time_zone.

Verification

  • Script logs counts: rows read, pages created, updated, skipped, failed (with page IDs).
  • Re-query the data source with the same filter and compare counts with the source system.
  • Open one created page in Notion and check properties and block rendering.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Notion API"
  short_description: "Sync with Notion databases without 404 surprises"
  default_prompt: "$notion-api Sync our GitHub issues into a Notion database every hour"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem