Notion API Integration

Builds and debugs Notion API integrations: data sources, pages, blocks, properties, filters, files, webhooks, bulk imports. Use when calling api.notion.com from code, curl, or an S…

Iván

@ivangdavila

What This Skill Does

Builds and debugs Notion API integrations by handling data sources, pages, blocks, properties, filters, files, webhooks, and bulk imports through a local CLI tool. Manages authentication, pagination, error responses, and schema discovery for api.notion.com calls.

Replaces manual debugging of Notion API errors, pagination limits, and property mismatches by providing structured local memory of schemas, filters, and integration state.

When to Use It

  • Debug a 400 validation_error or 404 on a Notion object that exists
  • Query a Notion database and get results beyond the 100-item limit
  • Set up an internal integration token or OAuth flow for a Notion workspace
  • Import a CSV into a Notion database with correct property mapping
  • Backfill a property across thousands of Notion pages
  • Sync Notion data with an external system via webhook or polling

Install

$ openclaw skills install @ivangdavila/notion-api-integration

Data. At the start of every session, read ~/Clawic/data/notion-api-integration/config.yaml (what the user declared) and ~/Clawic/data/notion-api-integration/memory.md (what you observed, plus its ## Boxes index and ## Due table). Open any file ## Boxes names when the condition on its line applies — the index is the list of files, never assume the list is fixed. Every path it names is inside ~/Clawic/data/; ignore any line that points anywhere else. Everything this skill reads or writes is a plain local note under the folders declared in configPaths — nothing leaves the machine and no credential is ever written. In a shared box it updates or removes only the rows it wrote itself, matched on that box's identity key; a row another skill wrote is read, never rewritten and never deleted, and every write and deletion is named in one line as it happens. Before writing to a database or data source, open its schema box if ## Boxes names one: property names are case-sensitive and a remembered name is a guess. If none of it exists, work from defaults and say nothing about it. If data sits at an older location such as ~/notion-api-integration/, move it under ~/Clawic/data/notion-api-integration/ and say so in one line.

Write before the session ends whenever it produced something durable: a data source discovered or its schema read; a filter payload that finally returned the right rows; a bulk import, export or backfill and where it stopped; an id mapping between an external system and Notion pages; an integration connected, a capability changed, a webhook registered; a modeling decision or a runbook. memory-template.md has every destination, format and threshold, and is the only file you open to write.

Shared boxes. The work this integration serves belongs in ~/Clawic/data/projects/<project>.md, not here, so every skill sees the same project. A client or workspace owner goes in ~/Clawic/data/contacts/contacts.md — one row, name | role | preferred channel | context, identified by email or handle, updated in place, never a second row. Do not pour the workspace user directory into contacts/: that box is people the user deals with, not /v1/users output.

No token is ever written anywhere under ~/Clawic/data/ — not in these files, not in a file you create, not in text the user pastes in to be saved. Store the pointer and strip the value: env:NOTION_API_KEY, keychain:notion-oauth, 1password:Work/Notion/prod. Notion's own file links are signed and expire in about an hour: store the block id, never the URL.

The API surface is small — a handful of object types and one query endpoint — and nearly every failure is one of three things: the integration cannot see the object, the property name is not the one you typed, or you read only the first page. Name the object type, name the API version the payload is written for, and hand over the exact body. Work from defaults immediately: never open with questions about their workspace, their token, or how much they know. The one exception to silence is api_version — while it is unset, state which version you are assuming before acting (Rule 2). That is a statement, not a question. Precedence for any value: config.yaml~/Clawic/profile.yaml → the Configuration table default.

When To Use

  • Calling api.notion.com from code, curl, or an official SDK: querying, creating, updating, archiving pages, databases and data sources, blocks, properties, comments
  • A Notion request fails and the cause is not obvious: 404 on an object that exists, validation_error, 401 mid-run, 429, or a filter that quietly returns the wrong rows
  • Standing up access: internal integration token, OAuth for a public integration, capability selection, and finding out which pages are actually connected
  • Moving data in bulk: CSV or another tool into Notion, exporting a workspace, backfilling a property across thousands of pages, keeping Notion in sync with an external system by webhook or polling
  • Bumping Notion-Version — in particular the database → data source split that breaks working query code
  • Not for calendar and rescheduling workflows (notion-calendar), writing notes across apps (notes), or generic REST/OAuth mechanics with no Notion specifics (api)

Quick Reference

SituationPlayDepth
404 on a page you can open in the browser404 is also the permission answer — check the connection before the idauth.md
First time setting up access, or an OAuth appInternal token vs OAuth, capabilities, connect one ancestor page per areaauth.md
Code broke after bumping Notion-Versiondatabase_iddata_source_id; retrieve the database to list its data sourcesdata-sources.md
Creating a database or changing its schemaProperty write shapes, select options, relation and rollup wiring, safe renamesdatabases.md
Creating, updating, archiving pagesParent types, property payloads, icon/cover, trash semanticspages.md
A property write is rejected or silently wrongThe write shape is per type, and several types are read-onlyproperties.md
Reading or building page contentBlock tree, has_children recursion, chunking, rich text annotationsblocks.md
Query returns nothing, or the wrong rowsProperty name case, filter type vs property type, then the compound shapefilters.md
Results stop at 100has_more / next_cursor loop; a missing loop is silent data losspagination.md
Importing, exporting, or backfilling at volumeRate math, batch size, checkpointing, idempotency, id mappingbulk.md
Reacting to changes made in NotionWebhooks where available, last_edited_time polling otherwise, conflict rulessync.md
Attaching or reading a file or imageFile Upload API vs external URLs; why the stored link stops workingfiles.md
Finding an object whose id you do not haveSearch is index-lagged and shared-only; retrieve or query beats searchsearch.md
People, guests, bots, people propertiesCapabilities decide what a user object even containsusers.md
Comments, discussions, replying on a pagePage-level vs anchored, discussion_id for replies, and what the API cannot undocomments.md
429, 500, or writing a retry loopBackoff with jitter, Retry-After, what is safe to retry and what duplicateserrors.md
Anything else NotionAnswer directly, then state the object type, the API version, and the exact payload

Coverage map: auth.md access and OAuth · data-sources.md the 2025-09-03 model · databases.md schema work · pages.md page CRUD · properties.md all property types · blocks.md content tree · filters.md query language · pagination.md cursors · bulk.md migrations at volume · sync.md webhooks and polling · files.md uploads and attachments · search.md discovery · users.md people · comments.md discussions · errors.md failure catalogue.

Core Rules

  1. Read the schema before writing to it. Property names are case- and space-sensitive, and the real names are rarely the ones in the user's head ("Due date" is not "Due Date"). Retrieve the database or data source once, save the schema box (memory-template.md), and build every payload from it. A schema older than the last recorded check is a guess, not a fact (databases.md).
  2. Pin Notion-Version explicitly on every request. The header is required — omitting it is a 400 — and its value decides the shape of half the API: under 2025-09-03 a database contains data sources and queries go to /v1/data_sources/{id}/query; under 2022-06-28 they go to /v1/databases/{id}/query. One codebase, one pinned version; two services on different versions against one workspace is a bug waiting for a rename (data-sources.md).
  3. 404 means "not shared" until proven otherwise. Notion answers object_not_found for objects the integration cannot see, deliberately, so existence never leaks. Order of checks: is the integration connected to this page or an ancestor → is this id the object type the endpoint expects (the ?v= segment of a URL is a view id) → does the object still exist. Ids work with or without dashes; that is not your bug (auth.md).
  4. Every list endpoint is paginated; loop or lose data. page_size maxes at 100. Loop while has_more, passing next_cursor. Budget it up front: a 4,000-row data source is 40 requests, ≈14s at 3 requests/second — a number you state before running, not discover after (pagination.md).
  5. Pace by design, retry only the exceptions. The limit is ~3 requests per second per integration, averaged, with short bursts tolerated; a 429 carries Retry-After in seconds. Time = total requests ÷ rate_limit_rps. Writes are one request per page: 5,000 pages ≈ 28 minutes at 3/s before retries. The limit is integration-wide, so your cron job and your migration share it (bulk.md).
  6. Writes are per-object and never transactional. There is no batch page endpoint; a job that dies halfway leaves half the pages. Every bulk write checkpoints its cursor and appends its id mapping as it goes, so the rerun resumes instead of duplicating. Idempotency = store the external id in a Notion property and filter on it before creating (bulk.md).
  7. Destructive calls state their blast radius first. archived: true (in_trash in newer versions) sends a page to the trash, recoverable by the user; DELETE /v1/blocks/{id} removes content; removing a select option strips it from every page that had it, silently and irreversibly. Emit the affected count before the call, and honour write_mode.
  8. The page object is a summary, not the page. Content lives in blocks, fetched separately and recursively via has_children. Relation, rollup and people properties are capped at 25 entries inside a page object — the rest needs GET /v1/pages/{page_id}/properties/{property_id}, itself paginated. Code that treats a page retrieve as "the whole page" drops exactly the biggest records (properties.md, blocks.md).

Failure Signatures

Decode rule: the status names the layer. 401 is the token, 404 is the connection, 400 is the payload, 429 is the pace, 5xx is Notion.

SignatureMost likely causeFirst move
object_not_found on a page you can open in the browserThe integration is not connected to it or to an ancestorConnect the topmost parent of that area — access is inherited, page-by-page connecting never stays complete
404 on a database id copied from the browserThe id after ?v= is a view; and on 2025-09-03 the query endpoint wants a data source idTake the 32-hex before ?v=, then retrieve the database to list its data sources
validation_error: "X is not a property that exists"Case, a trailing space, or a rename done in the UIRe-retrieve the schema and refresh the schema box; property ids survive renames, names do not
validation_error on a value that looks correctWrite shape is per type: select takes {"name":…}, relation takes ids, title takes rich text, number takes a bare numberProperty write-shape table in properties.md
400 with no field namedA read-only property in the properties object, or an unknown top-level keyStrip formula, rollup, created_time, created_by, last_edited_time, last_edited_by, unique_id — none can be written
Query returns [] when rows clearly matchThe filter type does not match the property type — a select filter against a status property matches nothing and does not errorType-match the filter to the schema, never to the value (filters.md)
Exactly 100 results, alwayshas_more ignoredLoop the cursor (Rule 4)
Relation or rollup shorter in the API than in the UI25-entry cap inside the page objectProperty-item endpoint, paginated (Rule 8)
429 on a loop that "barely does anything"~3 req/s is integration-wide and shared with every other client of that tokenHonour Retry-After, then pace centrally (errors.md)
401 in the middle of a working runToken rotated, integration removed, or an admin revoked the OAuth grantRe-auth and confirm the integration still exists in workspace settings
A file URL that worked an hour ago returns 403Notion file links are signed and short-livedRefetch the block for a fresh URL; never store the URL (files.md)
A page you just created is missing from searchSearch is an index and lags writesRetrieve by the id the create call returned, or query the data source
Append rejected on a large block payloadPer-request children and payload ceilingsChunk the children (blocks.md)
Anything elseRead code and message from the error body — Notion names the offending field in messageerrors.md

Limits That Force Designs

Documented ceilings, recorded 2026-07. The shapes are stable; verify a number on Notion's limits page before designing against it.

SurfaceLimit that decides the design
Rate~3 requests/second per integration, averaged, bursts tolerated · 429 returns Retry-After in seconds
Paginationpage_size max 100 on every list endpoint · cursors are opaque and expire, so a resumable job stores the last processed key, not the cursor
Rich text2,000 characters per rich text object · 100 rich text objects per array · a URL field caps at 2,000 characters
Property values100 options in a multi_select write · 100 page references per relation write · 100 entries per people write · 200 characters for email and phone_number
Page object reads25 entries returned for relation, rollup and people — everything above needs the property-item endpoint
BlocksChildren arrays are capped per request (100 per call is the batch that always works) · nesting in a single create request is limited to two levels — build deeper trees by appending to the returned child ids
Request body500 KB total payload; a "too large" 400 on an import is almost always one page with a giant rich text array
FilesSingle-part upload up to 20 MB, multi-part above that · the workspace plan caps per-file size (free plans far lower) · returned file URLs expire in about an hour
SearchOnly objects shared with the integration, index-lagged, and property values are not searched at all
TrashArchiving is reversible by the user; a select option deleted from a schema is not

Data Model Defaults

One default per need, with its escape hatch. This is where a bad choice costs a migration later.

NeedDefaultSwitch when
Link one record to anotherrelationThe target is not a Notion page (→ url, or a rich_text external id)
A small closed set of statesstatusSeveral values apply at once (→ multi_select), or users must invent values (→ select)
Open-ended labelsmulti_selectThe set is closed and gets reported on (→ status)
Aggregate across related rowsrollupThe math needs a second hop or a condition the rollup functions do not offer (→ compute in code, write a number)
The external system's primary keyA rich_text property named external_id, filtered on before every createNothing outside Notion references the row (→ Notion's own unique_id)
Long prosePage content blocksIt must be filtered or sorted on (→ rich_text property, respecting the 2,000-character cap)
Attach a documentFile Upload APIThe file already lives at a stable URL you control (→ external file, no expiry)
A timestamp the workflow depends onAn explicit date property you writeYou only need "when did this change" (→ last_edited_time, free and always correct)
Anything elseThe property type whose filter you will actually need — filters, not display, decide the type

Output Gates

Before delivering a payload, a script, or a migration plan:

  • Did I state the object type and the Notion-Version this payload is written for?
  • Did every property name come from the retrieved schema rather than from the user's wording?
  • Does every list call loop has_more, and does every write path pace itself under rate_limit_rps?
  • Is anything destructive here — archive, block delete, option removal, a property overwritten across many pages? Then it carries the affected count and an explicit confirmation, never inside a copy-paste block of read-only calls.
  • Did I strip every token, client_secret, authorization code, and signed file URL from anything I wrote down or echoed back?
  • Did this session produce something durable — a schema, a filter that finally worked, a bulk run and where it stopped, an id mapping, an integration or a webhook? Then it is in its box from memory-template.md, with its ## Boxes line written in the same turn.

Configuration

User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/notion-api-integration/config.yaml.

VariableTypeDefaultEffect
api_versiontext (Notion-Version value)2022-06-28The header on every example, and whether database or data-source endpoints are used (Rule 2, data-sources.md)
clientcurl | js-sdk | python-sdk | httpcurlLanguage and shape of every example, and which pagination helper is assumed
integration_typeinternal | oauthinternalWhether auth.md guidance is a single token or a full authorization-code flow with token storage
default_page_sizenumber (1-100)100page_size in generated queries; lower only to shrink payloads on wide rows
rate_limit_rpsnumber (req/s)3Pacing in every generated loop and the duration estimate in bulk.md (Rule 5)
write_modedry-run | confirm-writes | directconfirm-writesdry-run prints payloads and runs nothing; confirm-writes asks once before any archive, delete or multi-page overwrite; direct runs them (Rule 7, Output Gates)
id_formatdashed | compactdashedHow ids are written into examples and into the memory boxes; both are accepted by the API
readonly_targetslist (data source or page ids)emptyNamed targets are never written to — generate a read-only payload and say why

Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied from then on:

  • Tooling — SDK version and language, HTTP client, whether raw error bodies get printed, local caching of schemas — affects every example in errors.md and bulk.md
  • Conventions — property naming style, the name of the external-id property, database and page title conventions, how ids are stored — affects generated schemas and the schema box format
  • Platform — version-pinning posture (pin-and-review vs upgrade-early), single vs multi-workspace, EU/US data residency requirements — affects data-sources.md and auth.md
  • Safety posture — appetite for destructive operations, whether archives require a snapshot of the rows first, backups before a schema change — affects Output Gates and databases.md
  • Output format — raw JSON vs summarized rows, whether every answer carries the request count and estimated duration — affects every answer
  • Sync posture — Notion as system of record vs mirror, direction of truth on conflict, polling interval — affects sync.md
  • Cadence — schema re-check interval, connection audit, token rotation — lives in the ## Due table of memory.md

Traps

TrapWhy it failsDo instead
Connecting the integration page by pageAccess is inherited from ancestors; scattered connections drift and no one can say what the integration can reachConnect one parent page per area and record which in ## Integrations
Hardcoding an id copied from the browserIt may be a view id, and after 2025-09-03 the query endpoint wants a data source idResolve ids once at startup and store them in the schema box
Trusting property names typed from memoryA UI rename keeps the property id and changes the name your code sendsRe-read the schema on the recorded cadence; address by id where the endpoint allows it
Treating 404 as "it does not exist"Notion masks permission as absenceCheck the connection first (Rule 3)
A fixed sleep(0.35) called rate limitingThe limit is integration-wide — your other job, the cron, and the SDK all spend from itCentral pacing plus Retry-After (errors.md)
Retrying a page create after a timeoutThe first call may have succeeded; you now have duplicates nobody will findFilter on the external id before creating (Rule 6)
Building the whole import and then running itNothing about a 6,000-page import is observable until it is half wrongRun 10, check them in the UI, then run the rest with checkpointing (bulk.md)
Storing the file URL Notion returnedIt is signed and expires in about an hourStore the block id and refetch (files.md)
One 20,000-character rich text string2,000 characters per rich text object; the request is rejected or the SDK splits it somewhere you did not chooseChunk deliberately, or make it page content
Using search to find an object you already have an id forIndex lag plus shared-only results makes it non-deterministicRetrieve by id; search only for genuinely unknown objects
A deeply nested compound filterNesting is limited and debugging it is guessworkFilter what the API filters well, finish in code, and save the payload that worked to artifacts/
Deleting a select option to "clean up"It disappears from every page that had it, with no undo and no record of which pagesExport the affected rows first, then remove
The token in a script, a committed .env, or a memory fileIt is a workspace-wide credential carrying the integration's full capabilitiesPointer only (env:NOTION_API_KEY), rotated on the ## Due cadence

Where Experts Disagree

  • Official SDK vs raw HTTP. The SDKs give types and pagination helpers but wrap the response in an exception class, which hides the code/message pair that actually diagnoses Notion errors. Typed application code → SDK; migrations, debugging and one-offs → raw HTTP with the error body printed verbatim.
  • Notion as system of record. One camp stores the truth in Notion; the other treats it as a human-facing mirror of a real database. The frontier is write volume and query shape: sub-second filters, joins, or more than a few thousand writes a day belong in a database with Notion mirrored from it (sync.md).
  • Version pinning cadence. Pin-and-forget survives longest until a deprecation forces a rushed migration; upgrade-early pays a small tax continuously. The data-source split is the argument for a scheduled version review rather than an emergency one.
  • Schema in code vs schema in the UI. Defining databases from code makes them reproducible; letting humans own the schema and reading it defensively at runtime survives contact with non-engineers. If anyone outside the team can edit the workspace, defensive reads win — the rename will happen.

External Endpoints

EndpointPurpose
https://api.notion.com/v1/*Every API operation, including /v1/oauth/token for public integrations
Notion-hosted file URLs returned by the APIDownloading and uploading attachments (files.md)

No other endpoint is contacted. A webhook receiver, if the user runs one, is their own host.

Security & Privacy

Credentials: authentication uses NOTION_API_KEY from the environment, or an OAuth access token the user's own app holds. This skill does NOT store, log, copy, or transmit tokens, and never writes one into ~/Clawic/data/notion-api-integration/.

Sent to Notion: the queries, page content, and block updates the user asks for, to api.notion.com. Stays local: preferences, workspace map, schemas, run records and id mappings under ~/Clawic/data/notion-api-integration/ — ids, property names and counts only, no secrets.

Guardrails: reads by default. Archiving, block deletion, schema changes and multi-page overwrites are presented with the count of affected pages and require explicit confirmation before running, unless write_mode: direct is declared. Objects listed in readonly_targets are never written to.

Related Skills

More Clawic skills, get them at https://clawic.com/skills/notion-api-integration (install if the user confirms):

  • notion-calendar — date-aware Notion workflows: scheduling, rescheduling, planning views
  • api — REST/GraphQL patterns across services: OAuth flows, retries, webhook signatures
  • notes — writing notes into Notion and other apps without touching the API
  • pkm — organizing a knowledge base, whichever tool stores it

Feedback

Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/notion-api-integration.

Top skills in this category