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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- notion-api Installs to ~/.agents/skills/notion-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install notion-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project notion-api Installs to .agents/skills/notion-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/notion-api.zipunzip -o notion-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) notion-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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 usedata_source_id. - Token in env (
NOTION_TOKEN); readAGENTS.mdfor 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 type | Filter key example | Write 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
| Limit | Value | Handle by |
|---|---|---|
| Children per append request | 100 blocks | Chunk the list |
| Nesting per request | 2 levels | Append deeper children in follow-up calls using returned block IDs |
| Text per rich text object | 2,000 characters | Split long strings into several rich text items |
| Payload size | about 500 KB | Chunk |
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_errorhappens 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.retrieveand 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
archivedwas renamed toin_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.yaml
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.