Install
openclaw skills install @ivangdavila/notion-api-integrationBuilds 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 SDK, when a request returns 404 on an object that exists, a 400 validation_error, a 401, or a 429, when a database query returns the wrong rows or none at all, when results stop at 100, when a relation or rollup comes back with only part of its entries, when property names or select options do not match, when Notion-Version has to be bumped and database_id becomes data_source_id, when setting up an internal integration token or an OAuth flow, when importing a CSV or another tool's data into a workspace, exporting it, backfilling a property across thousands of pages, syncing Notion with an external system by webhook or polling, or uploading and attaching files. Not for calendar and rescheduling workflows (notion-calendar), writing notes across apps (notes), or generic REST and OAuth mechanics with no Notion specifics (api).
openclaw skills install @ivangdavila/notion-api-integrationData. 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.
validation_error, 401 mid-run, 429, or a filter that quietly returns the wrong rowsNotion-Version — in particular the database → data source split that breaks working query codenotion-calendar), writing notes across apps (notes), or generic REST/OAuth mechanics with no Notion specifics (api)| Situation | Play | Depth |
|---|---|---|
| 404 on a page you can open in the browser | 404 is also the permission answer — check the connection before the id | auth.md |
| First time setting up access, or an OAuth app | Internal token vs OAuth, capabilities, connect one ancestor page per area | auth.md |
Code broke after bumping Notion-Version | database_id → data_source_id; retrieve the database to list its data sources | data-sources.md |
| Creating a database or changing its schema | Property write shapes, select options, relation and rollup wiring, safe renames | databases.md |
| Creating, updating, archiving pages | Parent types, property payloads, icon/cover, trash semantics | pages.md |
| A property write is rejected or silently wrong | The write shape is per type, and several types are read-only | properties.md |
| Reading or building page content | Block tree, has_children recursion, chunking, rich text annotations | blocks.md |
| Query returns nothing, or the wrong rows | Property name case, filter type vs property type, then the compound shape | filters.md |
| Results stop at 100 | has_more / next_cursor loop; a missing loop is silent data loss | pagination.md |
| Importing, exporting, or backfilling at volume | Rate math, batch size, checkpointing, idempotency, id mapping | bulk.md |
| Reacting to changes made in Notion | Webhooks where available, last_edited_time polling otherwise, conflict rules | sync.md |
| Attaching or reading a file or image | File Upload API vs external URLs; why the stored link stops working | files.md |
| Finding an object whose id you do not have | Search is index-lagged and shared-only; retrieve or query beats search | search.md |
People, guests, bots, people properties | Capabilities decide what a user object even contains | users.md |
| Comments, discussions, replying on a page | Page-level vs anchored, discussion_id for replies, and what the API cannot undo | comments.md |
| 429, 500, or writing a retry loop | Backoff with jitter, Retry-After, what is safe to retry and what duplicates | errors.md |
| Anything else Notion | Answer 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.
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).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).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).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).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).bulk.md).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.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).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.
| Signature | Most likely cause | First move |
|---|---|---|
object_not_found on a page you can open in the browser | The integration is not connected to it or to an ancestor | Connect the topmost parent of that area — access is inherited, page-by-page connecting never stays complete |
| 404 on a database id copied from the browser | The id after ?v= is a view; and on 2025-09-03 the query endpoint wants a data source id | Take 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 UI | Re-retrieve the schema and refresh the schema box; property ids survive renames, names do not |
validation_error on a value that looks correct | Write shape is per type: select takes {"name":…}, relation takes ids, title takes rich text, number takes a bare number | Property write-shape table in properties.md |
| 400 with no field named | A read-only property in the properties object, or an unknown top-level key | Strip formula, rollup, created_time, created_by, last_edited_time, last_edited_by, unique_id — none can be written |
Query returns [] when rows clearly match | The filter type does not match the property type — a select filter against a status property matches nothing and does not error | Type-match the filter to the schema, never to the value (filters.md) |
| Exactly 100 results, always | has_more ignored | Loop the cursor (Rule 4) |
| Relation or rollup shorter in the API than in the UI | 25-entry cap inside the page object | Property-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 token | Honour Retry-After, then pace centrally (errors.md) |
| 401 in the middle of a working run | Token rotated, integration removed, or an admin revoked the OAuth grant | Re-auth and confirm the integration still exists in workspace settings |
| A file URL that worked an hour ago returns 403 | Notion file links are signed and short-lived | Refetch the block for a fresh URL; never store the URL (files.md) |
| A page you just created is missing from search | Search is an index and lags writes | Retrieve by the id the create call returned, or query the data source |
| Append rejected on a large block payload | Per-request children and payload ceilings | Chunk the children (blocks.md) |
| Anything else | Read code and message from the error body — Notion names the offending field in message | errors.md |
Documented ceilings, recorded 2026-07. The shapes are stable; verify a number on Notion's limits page before designing against it.
| Surface | Limit that decides the design |
|---|---|
| Rate | ~3 requests/second per integration, averaged, bursts tolerated · 429 returns Retry-After in seconds |
| Pagination | page_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 text | 2,000 characters per rich text object · 100 rich text objects per array · a URL field caps at 2,000 characters |
| Property values | 100 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 reads | 25 entries returned for relation, rollup and people — everything above needs the property-item endpoint |
| Blocks | Children 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 body | 500 KB total payload; a "too large" 400 on an import is almost always one page with a giant rich text array |
| Files | Single-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 |
| Search | Only objects shared with the integration, index-lagged, and property values are not searched at all |
| Trash | Archiving is reversible by the user; a select option deleted from a schema is not |
One default per need, with its escape hatch. This is where a bad choice costs a migration later.
| Need | Default | Switch when |
|---|---|---|
| Link one record to another | relation | The target is not a Notion page (→ url, or a rich_text external id) |
| A small closed set of states | status | Several values apply at once (→ multi_select), or users must invent values (→ select) |
| Open-ended labels | multi_select | The set is closed and gets reported on (→ status) |
| Aggregate across related rows | rollup | The 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 key | A rich_text property named external_id, filtered on before every create | Nothing outside Notion references the row (→ Notion's own unique_id) |
| Long prose | Page content blocks | It must be filtered or sorted on (→ rich_text property, respecting the 2,000-character cap) |
| Attach a document | File Upload API | The file already lives at a stable URL you control (→ external file, no expiry) |
| A timestamp the workflow depends on | An explicit date property you write | You only need "when did this change" (→ last_edited_time, free and always correct) |
| Anything else | The property type whose filter you will actually need — filters, not display, decide the type | — |
Before delivering a payload, a script, or a migration plan:
Notion-Version this payload is written for?has_more, and does every write path pace itself under rate_limit_rps?client_secret, authorization code, and signed file URL from anything I wrote down or echoed back?memory-template.md, with its ## Boxes line written in the same turn.User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/notion-api-integration/config.yaml.
| Variable | Type | Default | Effect |
|---|---|---|---|
| api_version | text (Notion-Version value) | 2022-06-28 | The header on every example, and whether database or data-source endpoints are used (Rule 2, data-sources.md) |
| client | curl | js-sdk | python-sdk | http | curl | Language and shape of every example, and which pagination helper is assumed |
| integration_type | internal | oauth | internal | Whether auth.md guidance is a single token or a full authorization-code flow with token storage |
| default_page_size | number (1-100) | 100 | page_size in generated queries; lower only to shrink payloads on wide rows |
| rate_limit_rps | number (req/s) | 3 | Pacing in every generated loop and the duration estimate in bulk.md (Rule 5) |
| write_mode | dry-run | confirm-writes | direct | confirm-writes | dry-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_format | dashed | compact | dashed | How ids are written into examples and into the memory boxes; both are accepted by the API |
| readonly_targets | list (data source or page ids) | empty | Named 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:
errors.md and bulk.mddata-sources.md and auth.mddatabases.mdsync.md## Due table of memory.md| Trap | Why it fails | Do instead |
|---|---|---|
| Connecting the integration page by page | Access is inherited from ancestors; scattered connections drift and no one can say what the integration can reach | Connect one parent page per area and record which in ## Integrations |
| Hardcoding an id copied from the browser | It may be a view id, and after 2025-09-03 the query endpoint wants a data source id | Resolve ids once at startup and store them in the schema box |
| Trusting property names typed from memory | A UI rename keeps the property id and changes the name your code sends | Re-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 absence | Check the connection first (Rule 3) |
A fixed sleep(0.35) called rate limiting | The limit is integration-wide — your other job, the cron, and the SDK all spend from it | Central pacing plus Retry-After (errors.md) |
| Retrying a page create after a timeout | The first call may have succeeded; you now have duplicates nobody will find | Filter on the external id before creating (Rule 6) |
| Building the whole import and then running it | Nothing about a 6,000-page import is observable until it is half wrong | Run 10, check them in the UI, then run the rest with checkpointing (bulk.md) |
| Storing the file URL Notion returned | It is signed and expires in about an hour | Store the block id and refetch (files.md) |
| One 20,000-character rich text string | 2,000 characters per rich text object; the request is rejected or the SDK splits it somewhere you did not choose | Chunk deliberately, or make it page content |
| Using search to find an object you already have an id for | Index lag plus shared-only results makes it non-deterministic | Retrieve by id; search only for genuinely unknown objects |
| A deeply nested compound filter | Nesting is limited and debugging it is guesswork | Filter 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 pages | Export the affected rows first, then remove |
The token in a script, a committed .env, or a memory file | It is a workspace-wide credential carrying the integration's full capabilities | Pointer only (env:NOTION_API_KEY), rotated on the ## Due cadence |
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.sync.md).| Endpoint | Purpose |
|---|---|
https://api.notion.com/v1/* | Every API operation, including /v1/oauth/token for public integrations |
| Notion-hosted file URLs returned by the API | Downloading and uploading attachments (files.md) |
No other endpoint is contacted. A webhook receiver, if the user runs one, is their own host.
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.
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 viewsapi — REST/GraphQL patterns across services: OAuth flows, retries, webhook signaturesnotes — writing notes into Notion and other apps without touching the APIpkm — organizing a knowledge base, whichever tool stores itPart of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/notion-api-integration.