agentleFS
ShanksgShanksg/clawskills/skills/notion/skill.md

notion

Skill2 starsChanged 3 months agoMITView on GitHub

Last validated: 2026-10-14 | API version: 2026-03-11 (latest stable) REST base URL: https://api.notion.com/v1/ Assumed product: Notion (cloud). The Notion-Version: 2026-03-11 header is required on every request. ⚠️ Breaking changes in 2026-03-11 (released March 11, 2026 — previous stable was 2025-09-03): - archived → in_trash on pages, databases, blocks, and data sources, in both requests and responses. Any code reading archived or sending {"archived": true} must be updated. - Block positioning: the flat "after": "<block-id>" parameter is replaced by a structured…

  • Sends data out

98 other projects use a version of this, 4 of them edited.

Adopted by

Most recently taken up by

Used with

Projects with it also use taskflow-inbox-triage, voice-call and 10 more.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • AtomicBot-aiSkill

    taskflow-inbox-triage

    Also in 6 projects that use this

  • seedprodSkill

    voice-call

    Start voice calls via the OpenClaw voice-call plugin.

    Also in 17 projects that use this

  • jiulingyunSkill

    BlueBubbles

    为Clawdbot构建或更新BlueBubbles外部通道插件(扩展包,REST发送/探测,webhook入站)。

    Also in 17 projects that use this

  • seedprodSkill

    session-logs

    Search and analyze your own session logs (older/parent conversations) using jq.

    Also in 17 projects that use this

  • hkyutongSkill

    openclaw-release-maintainer

    Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.

    Also in 4 projects that use this

  • AtomicBot-aiSkill

    taskflow

    Also in 6 projects that use this

  • +5 more from seedprod

The file

Show its rules and text

We split this skill into the rules it gives an agent

15 rules. 0 are common, 10 are distinctive, and 5 are very specific to this project.

  • 0

    Common

    Most of the 93 edited versions we read keep these.

  • 10

    Distinctive

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Create and update pages (docs, notes, tasks, CRM records) programmatically inside any Notion workspace.

    line 18
  2. Distinctive:

    Write rich page content: headings, paragraphs, lists, callouts, code blocks, and more via the Blocks API.

    line 20
  3. Distinctive:

    Search across all workspace content (pages and databases) that the integration has access to.

    line 22
  4. Very specific:

    All IDs are UUIDv4 strings.

    line 56
  5. Very specific:

    When writing rich text, only text.content (and optionally text.link.url and annotations) are required.

    line 132
  6. Distinctive:

    When creating an integration, select only the capabilities you need:

    line 159
  7. Very specific:

    Use access_token as the Bearer token for all subsequent requests on behalf of that workspace.

    line 191
  8. Distinctive:

    Important: Search only covers pages and databases shared with your integration.

    line 490
  9. Very specific:

    Use "timestamp": "created_time" or "last_edited_time".

    line 604
  10. Very specific:

    Always paginate using start_cursor + has_more — do not assume 100 results means there are more.

    line 627
  11. Distinctive:

    Only enable the capabilities your integration actually needs:

    line 763
  12. Distinctive:

    All integration activity is visible in Notion → Settings → Connections (workspace owners).

    line 786
  13. Distinctive:

    Create a test workspace (free plan) or a test section in your workspace.

    line 794
  14. Distinctive:

    Create an internal integration at notion.so/my-integrations.

    line 795
  15. Distinctive:

    Create a test database and share it with the integration.

    line 796

“Common” is measured: kept by at least 6 in 10 of the 93 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
skill.md

Notion Skill

Last validated: 2026-10-14 | API version: 2026-03-11 (latest stable) REST base URL: https://api.notion.com/v1/ Assumed product: Notion (cloud). The Notion-Version: 2026-03-11 header is required on every request.

⚠️ Breaking changes in 2026-03-11 (released March 11, 2026 — previous stable was 2025-09-03):

  • archived → in_trash on pages, databases, blocks, and data sources, in both requests and responses. Any code reading archived or sending {"archived": true} must be updated.
  • Block positioning: the flat "after": "<block-id>" parameter is replaced by a structured position object — see Recipe 5.
  • transcription block type renamed to meeting_notes, along with its property object. Update block type checks.

If you are still pinned to 2025-09-03, that version keeps working, but every example below assumes 2026-03-11.


What this skill enables

  • Create and update pages (docs, notes, tasks, CRM records) programmatically inside any Notion workspace.
  • Query databases with filters, sorts, and pagination — pull structured data from any Notion table, board, or calendar.
  • Write rich page content: headings, paragraphs, lists, callouts, code blocks, and more via the Blocks API.
  • Sync external data into Notion: push records from CRMs, issue trackers, or alert systems into Notion databases.
  • Search across all workspace content (pages and databases) that the integration has access to.
  • Build lightweight internal tools: project trackers, content calendars, CRM boards, on-call runbooks — without leaving Notion.

Best-fit use cases

Use caseWhy it mattersPrimary objectsTypical triggerSuccess criteria
Sync CRM deals to Notion pipelineSales visibility in Notion without manual entryDatabase page, propertiesHubSpot/Salesforce webhook on deal stage changeNotion database row created/updated with correct stage, owner, amount
Create incident runbook pageAuto-generate a structured runbook when PagerDuty alert firesPage, BlocksPagerDuty/Alertmanager webhookPage created with headings, checklist, and links
Content calendar entryMarketing team manages publishing schedule in NotionDatabase page, date propertyCMS or manual triggerPage created with title, publish date, status, assignee
Bug/task trackerEngineering team tracks work in Notion instead of or alongside JiraDatabase page, select, relationCI failure, Sentry alertPage created in backlog with priority, assignee, reproduction steps
Weekly digest / standupSummarise activity from GitHub/Jira/Slack into a Notion docPage, BlocksCron job (Monday morning)Structured page with bullet points per team member
Knowledge base articleAuto-create docs from templates when a product feature shipsPage, BlocksGitHub release webhookPage created in wiki database with correct tags and cover
Meeting notes capturePush structured meeting notes from an AI summary into NotionPage, BlocksCalendly/Zoom webhook on meeting endPage in meetings database with attendees, action items, summary
Search-and-update pipelineFind pages by title/tag and bulk-update a propertySearch, PATCH pageScheduled jobAll matching pages updated; last_edited_time advances
Onboarding checklistCreate a personal checklist page for each new hirePage, to_do blocksHRIS onboarding eventPage with pre-checked and unchecked tasks, assigned to new hire
Incremental data syncPull all Notion pages modified since last run into a data warehouseDatabase query, last_edited_time filterScheduled ETL jobEvery modified page captured; no duplicates

Key concepts & data model

Core objects

ObjectDescriptionIdentified by
PageA document in Notion; also functions as a row in a databaseUUIDv4 id, e.g. "a1b2c3d4-..."
DatabaseA collection of pages sharing a property schema; can be inline or full-pageUUIDv4 id
BlockA unit of page content (paragraph, heading, list item, image, etc.)UUIDv4 id
UserA workspace member or integration botUUIDv4 id
CommentA discussion thread on a page or blockUUIDv4 id

All IDs are UUIDv4 strings. Notion returns them with hyphens: "a1b2c3d4-e5f6-7890-abcd-ef1234567890". When passing IDs in URL paths you can use either format (with or without hyphens).

Page vs database row

In Notion, a "row" in a database is just a Page whose parent is the database. Create rows by creating pages with "parent": {"database_id": "..."}. Every Page has a properties object whose shape is defined by the parent database's schema.

Parent types

parent.typeMeaning
"database_id"Page is a row in a database
"page_id"Page is a sub-page of another page
"workspace"Top-level page in the workspace
"block_id"Page is embedded inside a block

Version 2026-03-11 — trash, positioning, and meeting notes

The current stable version renames archived to in_trash across pages, databases, blocks, and data sources; replaces the flat after block-insertion parameter with a position object (Recipe 5); and renames the transcription block type to meeting_notes. These are the only breaking changes — everything else in this doc is unchanged from 2025-09-03.

Version 2025-09-03 — multi-source databases

Version 2025-09-03 introduced multi-source databases (databases that aggregate rows from multiple linked data sources), and that behaviour carries forward into 2026-03-11. For single-source databases (the vast majority), nothing changes. If your database uses multiple data sources, creating pages requires a data_source_id in the parent object:

"parent": { "type": "data_source_id", "data_source_id": "the-source-uuid" }

For all standard databases, continue using "database_id". The query endpoint POST /v1/databases/{id}/query continues to work unchanged.

Property types

TypeWritable?Notes
title✅Required on every database page; every database has exactly one
rich_text✅Up to 2,000 characters per value
number✅Integer or float
select✅Single option; pass {"name": "..."} (creates option if new)
multi_select✅Array of {"name": "..."} objects; max 100 options
date✅{"start": "YYYY-MM-DD", "end": null} or with time zone
people✅Array of {"object": "user", "id": "..."}
files✅External URLs only via API; Notion-hosted files read-only
checkbox✅Boolean
url✅String URL
email✅String
phone_number✅String
relation✅Array of {"id": "page-uuid"} — links to pages in related database
formula❌Read-only; computed by Notion
rollup❌Read-only; aggregated from relations
created_time❌Read-only
created_by❌Read-only
last_edited_time❌Read-only
last_edited_by❌Read-only

Rich text object

Used inside title, rich_text, block content, and more:

{
  "type": "text",
  "text": {
    "content": "Hello, world",
    "link": null
  },
  "annotations": {
    "bold": false,
    "italic": false,
    "strikethrough": false,
    "underline": false,
    "code": false,
    "color": "default"
  },
  "plain_text": "Hello, world"
}

When writing rich text, only text.content (and optionally text.link.url and annotations) are required. plain_text is ignored on writes.


Authentication & permissions

Integration types

TypeToken prefixUse
Internal integrationsecret_Single workspace; simplest setup; no OAuth needed
Public integration (OAuth)ntn_Multi-workspace; requires OAuth 2.0 flow

Internal integration setup

  1. Go to notion.so/my-integrations → New integration → fill in name and select workspace.
  2. Copy the Internal Integration Secret (secret_...).
  3. Share the page or database with the integration: open the page in Notion → … menu → Connections → add your integration. Without this step every API call returns object_not_found.
curl https://api.notion.com/v1/users/me \
  -H "Authorization: Bearer secret_abc123..." \
  -H "Notion-Version: 2026-03-11"

Integration capabilities

When creating an integration, select only the capabilities you need:

CapabilityWhat it allows
Read contentGET pages, databases, blocks, comments
Update contentPATCH pages and blocks
Insert contentPOST pages, blocks, comments
Read user information (no email)GET /v1/users (names and avatars only)
Read user email addressesGET /v1/users (includes email — restricted capability)

OAuth 2.0 (public integrations)

Used when your app accesses multiple users' workspaces:

1. Redirect user → https://api.notion.com/v1/oauth/authorize
     ?client_id=<your-client-id>
     &response_type=code
     &owner=user
     &redirect_uri=<your-callback-url>

2. User approves → Notion redirects back with ?code=<auth-code>

3. Exchange code for token:
   POST https://api.notion.com/v1/oauth/token
   Authorization: Basic base64(client_id:client_secret)
   Content-Type: application/json
   {"grant_type": "authorization_code", "code": "...", "redirect_uri": "..."}

4. Response: {"access_token": "ntn_...", "workspace_id": "...", "workspace_name": "...", "bot_id": "..."}

Use access_token as the Bearer token for all subsequent requests on behalf of that workspace.

Required headers on every request

Authorization: Bearer <token>
Notion-Version: 2026-03-11
Content-Type: application/json   (for POST/PATCH)

Common workflows (recipes)

1. Create a database row (page with properties)

curl -X POST https://api.notion.com/v1/pages \
  -H "Authorization: Bearer secret_..." \
  -H "Notion-Version: 2026-03-11" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": { "database_id": "your-database-uuid" },
    "properties": {
      "Name": {
        "title": [{ "text": { "content": "Fix login bug" } }]
      },
      "Status": {
        "select": { "name": "In Progress" }
      },
      "Priority": {
        "select": { "name": "High" }
      },
      "Due Date": {
        "date": { "start": "2026-03-15" }
      },
      "Assignee": {
        "people": [{ "object": "user", "id": "user-uuid-here" }]
      }
    }
  }'

Response (key fields):

{
  "object": "page",
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "https://www.notion.so/Fix-login-bug-a1b2c3d4e5f6...",
  "properties": { ... },
  "created_time": "2026-03-02T10:00:00.000Z",
  "last_edited_time": "2026-03-02T10:00:00.000Z"
}

2. Query a database with filters and sorts

import requests

NOTION_TOKEN = "secret_..."
DATABASE_ID = "your-database-uuid"

headers = {
    "Authorization": f"Bearer {NOTION_TOKEN}",
    "Notion-Version": "2026-03-11",
    "Content-Type": "application/json",
}

def query_database(filters=None, sorts=None):
    pages = []
    body = {"page_size": 100}
    if filters:
        body["filter"] = filters
    if sorts:
        body["sorts"] = sorts

    while True:
        resp = requests.post(
            f"https://api.notion.com/v1/databases/{DATABASE_ID}/query",
            headers=headers,
            json=body,
        )
        resp.raise_for_status()
        data = resp.json()
        pages.extend(data["results"])
        if not data["has_more"]:
            break
        body["start_cursor"] = data["next_cursor"]
    return pages

# Example: all high-priority open tasks due this week
results = query_database(
    filters={
        "and": [
            {"property": "Status", "select": {"equals": "In Progress"}},
            {"property": "Priority", "select": {"equals": "High"}},
            {"property": "Due Date", "date": {"next_week": {}}},
        ]
    },
    sorts=[{"property": "Due Date", "direction": "ascending"}],
)

3. Update page properties

curl -X PATCH https://api.notion.com/v1/pages/page-uuid \
  -H "Authorization: Bearer secret_..." \
  -H "Notion-Version: 2026-03-11" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "Status": { "select": { "name": "Done" } },
      "Due Date": { "date": { "start": "2026-03-20" } }
    }
  }'

To archive (move to trash) a page:

curl -X PATCH https://api.notion.com/v1/pages/page-uuid \
  -H "Authorization: Bearer secret_..." \
  -H "Notion-Version: 2026-03-11" \
  -H "Content-Type: application/json" \
  -d '{ "in_trash": true }'

4. Create a page with rich content (blocks)

blocks = [
    {
        "type": "heading_1",
        "heading_1": {
            "rich_text": [{"type": "text", "text": {"content": "Incident Runbook"}}]
        },
    },
    {
        "type": "callout",
        "callout": {
            "rich_text": [{"type": "text", "text": {"content": "P1 — all hands"}}],
            "icon": {"emoji": "🚨"},
            "color": "red_background",
        },
    },
    {
        "type": "heading_2",
        "heading_2": {
            "rich_text": [{"type": "text", "text": {"content": "Checklist"}}]
        },
    },
    {
        "type": "to_do",
        "to_do": {
            "rich_text": [{"type": "text", "text": {"content": "Acknowledge alert in PagerDuty"}}],
            "checked": False,
        },
    },
    {
        "type": "to_do",
        "to_do": {
            "rich_text": [{"type": "text", "text": {"content": "Notify #incidents Slack channel"}}],
            "checked": False,
        },
    },
    {
        "type": "code",
        "code": {
            "rich_text": [{"type": "text", "text": {"content": "kubectl get pods -n production"}}],
            "language": "shell",
        },
    },
]

response = requests.post(
    "https://api.notion.com/v1/pages",
    headers=headers,
    json={
        "parent": {"page_id": "parent-page-uuid"},
        "properties": {
            "title": [{"text": {"content": "Incident: DB latency spike 2026-03-02"}}]
        },
        "children": blocks,
    },
)
page = response.json()
print(page["url"])

Limits: Max 100 blocks per children array in a single request. For longer pages, create the page first then append in batches (Recipe #5).


5. Append blocks to an existing page

def append_blocks(page_id: str, blocks: list, batch_size: int = 100) -> None:
    """Append blocks in batches of up to 100."""
    for i in range(0, len(blocks), batch_size):
        batch = blocks[i : i + batch_size]
        resp = requests.patch(
            f"https://api.notion.com/v1/blocks/{page_id}/children",
            headers=headers,
            json={"children": batch},
        )
        resp.raise_for_status()

Inserting at a specific position. As of 2026-03-11 the flat after string is replaced by a position object. Omitting position still appends to the end:

{
  "children": [
    {
      "type": "paragraph",
      "paragraph": {
        "rich_text": [{ "type": "text", "text": { "content": "Inserted after the target block." } }]
      }
    }
  ],
  "position": {
    "type": "after_block",
    "after_block": { "id": "b5d8fd79-0000-0000-0000-000000000000" }
  }
}

position.type accepts after_block (insert after the given block — the replacement for the old after), start (prepend to the parent), and end (append; the default).

Pre-2026-03-11: the equivalent request body was {"after": "b5d8fd79-...", "children": [...]}. The old form is rejected on 2026-03-11.


6. Read all blocks from a page (recursive)

def get_all_blocks(block_id: str) -> list:
    """Fetch all blocks recursively, handling pagination and nesting."""
    blocks = []
    params = {"page_size": 100}
    while True:
        resp = requests.get(
            f"https://api.notion.com/v1/blocks/{block_id}/children",
            headers=headers,
            params=params,
        )
        resp.raise_for_status()
        data = resp.json()
        for block in data["results"]:
            blocks.append(block)
            if block.get("has_children"):
                block["children"] = get_all_blocks(block["id"])
        if not data["has_more"]:
            break
        params["start_cursor"] = data["next_cursor"]
    return blocks

Note: Notion does NOT return nested block content automatically — you must recurse for every block with has_children: true.


7. Search for pages and databases

def search_notion(query: str, filter_type: str = None) -> list:
    """
    filter_type: "page" | "database" | None (returns both)
    Searches titles only — does not full-text search page content.
    """
    body = {"query": query, "page_size": 100}
    if filter_type:
        body["filter"] = {"value": filter_type, "property": "object"}

    results = []
    while True:
        resp = requests.post(
            "https://api.notion.com/v1/search",
            headers=headers,
            json=body,
        )
        resp.raise_for_status()
        data = resp.json()
        results.extend(data["results"])
        if not data["has_more"]:
            break
        body["start_cursor"] = data["next_cursor"]
    return results

Important: Search only covers pages and databases shared with your integration. Content inside pages (block text) is not indexed by the search endpoint.


8. Incremental sync — pages modified since last run

from datetime import datetime, timezone

def get_modified_pages(database_id: str, since: datetime) -> list:
    """Fetch all database rows edited after `since`."""
    return query_database(
        filters={
            "timestamp": "last_edited_time",
            "last_edited_time": {
                "after": since.isoformat().replace("+00:00", "Z")
            },
        },
        sorts=[{"timestamp": "last_edited_time", "direction": "ascending"}],
    )

# Example — pull everything modified in the last hour
since = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
pages = get_modified_pages("your-database-uuid", since)

9. List workspace users

curl https://api.notion.com/v1/users \
  -H "Authorization: Bearer secret_..." \
  -H "Notion-Version: 2026-03-11"

Response includes type: "person" (workspace members) and type: "bot" (integrations). The person type includes person.email only if your integration has the Read user email addresses capability.


10. Retrieve a page and read its properties

resp = requests.get(
    f"https://api.notion.com/v1/pages/{page_id}",
    headers=headers,
)
page = resp.json()

# Extract title (works for any database page)
title_prop = next(
    v for v in page["properties"].values() if v["type"] == "title"
)
title = "".join(rt["plain_text"] for rt in title_prop["title"])

# Read a select property
status = page["properties"].get("Status", {}).get("select", {}).get("name")

# Read a date property
due_date = page["properties"].get("Due Date", {}).get("date", {}).get("start")

Query patterns & filtering

Filter structure

Filters are composed of property filters (test a specific property) combined with "and" / "or" arrays:

{
  "filter": {
    "and": [
      {
        "property": "Status",
        "select": { "equals": "In Progress" }
      },
      {
        "or": [
          { "property": "Priority", "select": { "equals": "High" } },
          { "property": "Priority", "select": { "equals": "Critical" } }
        ]
      }
    ]
  }
}

Property filter operators by type

Property typeOperators
rich_textequals, does_not_equal, contains, does_not_contain, starts_with, ends_with, is_empty, is_not_empty
numberequals, does_not_equal, greater_than, less_than, greater_than_or_equal_to, less_than_or_equal_to, is_empty, is_not_empty
selectequals, does_not_equal, is_empty, is_not_empty
multi_selectcontains, does_not_contain, is_empty, is_not_empty
dateequals, before, after, on_or_before, on_or_after, is_empty, is_not_empty, past_week, past_month, past_year, next_week, next_month, next_year
checkboxequals (true/false)
peoplecontains, does_not_contain, is_empty, is_not_empty
relationcontains, does_not_contain, is_empty, is_not_empty
filesis_empty, is_not_empty

Timestamp filters (not a property — built-in page timestamps)

{
  "filter": {
    "timestamp": "last_edited_time",
    "last_edited_time": { "after": "2026-03-01T00:00:00Z" }
  }
}

Use "timestamp": "created_time" or "last_edited_time".

Sorts

{
  "sorts": [
    { "property": "Due Date", "direction": "ascending" },
    { "property": "Priority", "direction": "descending" }
  ]
}

Sort by timestamp: { "timestamp": "last_edited_time", "direction": "descending" }.

Pagination

ParameterDefaultMaxNotes
page_size100100Maximum is 100 for database queries and block children
start_cursor——Value of next_cursor from the previous response
has_more——false when there are no more pages

Always paginate using start_cursor + has_more — do not assume 100 results means there are more.


Reliability: rate limits, retries, idempotency

Rate limits

⚠️ Changed 2026-09-09: the flat "~3 req/sec" average is replaced by a fixed 60-second window per connection, sized by workspace plan, plus a separate shared per-workspace budget and some per-endpoint limits. Since 2026-09-24, 429/529 bodies carry additional_data.retry_after and additional_data.rate_limit_reason. Source: Request limits, changelog.

LimitValueNotes
Per connection600 req/min (Business, Enterprise) / 180 req/min (all other plans)Fixed 60-second window; spend at any pace within it. Retry-After ≤ 60 s
Per workspaceShared budget across all of the workspace's connections, scales with planCan throttle you even when your connection is under its limit; Retry-After may exceed 60 s
Per endpointSome endpoints have their own limitsrate_limit_reason: public_api_endpoint_rate_limit
Response on limitHTTP 429, code "rate_limited"; additional_data.rate_limit_reason says which limitpublic_api_request_blocked means API access is restricted — do not retry
OverloadHTTP 529, code "service_overload"Retry exactly like a 429
Backoff headerRetry-After (integer seconds), echoed as additional_data.retry_after (string)Always honour this value

Retry with exponential backoff

import time
import requests

def notion_request(method: str, url: str, **kwargs) -> dict:
    headers = kwargs.pop("headers", {})
    headers.update({
        "Authorization": f"Bearer {NOTION_TOKEN}",
        "Notion-Version": "2026-03-11",
        "Content-Type": "application/json",
    })
    for attempt in range(5):
        resp = requests.request(method, url, headers=headers, **kwargs)
        if resp.status_code in (429, 529):
            wait = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        if resp.status_code in (500, 502, 503, 504):
            time.sleep(2 ** attempt)
            continue
        resp.raise_for_status()
        return resp.json()
    raise RuntimeError(f"Failed after 5 attempts: {url}")

Request size limits

LimitValue
Blocks per request (children array)100
Total request payload500 KB
Rich text field2,000 characters
URL / email / phone_number fields2,000 / 200 / 200 characters
Multi-select options100
Relation links100 per property
People mentions per property100

Idempotency

The Notion API does not provide an idempotency key mechanism. To avoid duplicate pages, query the database first (filter on a unique external ID stored in a rich_text or url property) before creating:

def upsert_page(database_id: str, external_id: str, properties: dict) -> dict:
    existing = query_database(
        filters={"property": "External ID", "rich_text": {"equals": external_id}}
    )
    if existing:
        page_id = existing[0]["id"]
        return notion_request("PATCH", f"https://api.notion.com/v1/pages/{page_id}", json={"properties": properties})
    return notion_request("POST", "https://api.notion.com/v1/pages", json={
        "parent": {"database_id": database_id},
        "properties": {**properties, "External ID": {"rich_text": [{"text": {"content": external_id}}]}},
    })

Error handling & troubleshooting

Error object structure

{
  "object": "error",
  "status": 404,
  "code": "object_not_found",
  "message": "Could not find database with ID: a1b2c3d4-..."
}

Status → code → action

StatusCodeMeaningFix
400invalid_jsonRequest body is not valid JSONCheck JSON encoding; ensure Content-Type: application/json
400invalid_request_urlURL is malformedVerify endpoint path and UUID format
400invalid_requestOperation not supportedRead the API docs for this endpoint
400validation_errorParameter shape is wrongCheck message for the specific field; verify property type JSON
400missing_versionNotion-Version header absentAdd Notion-Version: 2026-03-11 to all requests
401unauthorizedToken invalid or expiredVerify token prefix (secret_ or ntn_); check it wasn't revoked
403restricted_resourceIntegration lacks capabilityAdd the required capability in notion.so/my-integrations
404object_not_foundPage/DB not found or not sharedShare the page/database with your integration in Notion UI
409conflict_errorConcurrent write collisionRetry the request; consider optimistic locking with last_edited_time
429rate_limitedToo many requestsWait Retry-After seconds; use exponential backoff
500internal_server_errorNotion server errorRetry with backoff; check https://status.notion.so
503service_unavailableNotion offline or request timed out (60s limit)Retry; break up large block payloads
503database_connection_unavailableNotion DB unreachableRetry with backoff
504gateway_timeoutRequest processing timed outReduce payload size; retry
529service_overloadNotion temporarily overloadedWait Retry-After seconds; retry like a 429

Common issues

SymptomCauseFix
object_not_found on a valid IDIntegration not shared with the pageOpen page in Notion → … → Connections → add integration
restricted_resource on readIntegration missing "Read content" capabilityEdit integration at notion.so/my-integrations
validation_error on property writeWrong JSON structure for property typeCopy exact write structure from Key concepts section
Empty results array from queryDatabase not shared, or filter matches nothingCheck sharing first; test with no filter
missing_version on every requestNotion-Version header not setAdd header; if using a framework, set it as a default
Blocks truncatedResponse only returns first levelRecurse on has_children: true blocks
New page missing in searchSearch indexes with a delayWait a few seconds; query the database directly for freshness
503 on large page createRequest payload > 500 KB or > 100 blocksSplit into create + append batches

Security & compliance

Token storage

  • Store integration tokens in environment variables or a secrets manager — never hardcode in source.
  • Internal integration secrets (secret_...) have no expiry — rotate them manually in notion.so/my-integrations if compromised.
  • OAuth access tokens (ntn_...) may expire depending on workspace settings — implement refresh logic if building a public integration.

Least-privilege capabilities

Only enable the capabilities your integration actually needs:

Use caseRequired capabilities
Read-only reporting / syncRead content only
Creating pages / blocksRead content + Insert content
Updating existing pagesRead content + Update content
User lookup (names only)Read user information (no email)
User lookup with emailsRead user email addresses

Data access scope

Notion integrations only access what is explicitly shared with them. A page or database must be shared with the integration before the API can see it — there is no "access all workspace content" mode for internal integrations. This is a security feature: scope access narrowly by sharing only the databases your automation needs.

PII considerations

Notion databases often hold PII (names, emails, phone numbers). Ensure:

  • API responses containing PII are not logged in plaintext.
  • Access tokens are scoped to only the databases needed.
  • Data written to Notion from external sources is sanitised (rich text supports no HTML injection risk, but verify URL fields).

Audit trail

All integration activity is visible in Notion → Settings → Connections (workspace owners). Use meaningful integration names (e.g., "CRM Sync Bot") so admins can identify the source of changes.


Testing checklist

Setup for testing

  1. Create a test workspace (free plan) or a test section in your workspace.
  2. Create an internal integration at notion.so/my-integrations.
  3. Create a test database and share it with the integration.
  4. Store the integration token and database ID in environment variables.

QA checklist

  • GET /v1/users/me returns the bot user with correct name → confirms token is valid
  • POST /v1/pages with a title property creates a row in the test database
  • POST /v1/databases/{id}/query returns the newly created page
  • POST /v1/databases/{id}/query with a select filter returns only matching pages
  • POST /v1/databases/{id}/query with page_size=1 returns has_more: true; cursor retrieves next page
  • PATCH /v1/pages/{id} updates a select property; GET confirms new value
  • PATCH /v1/pages/{id} with "in_trash": true removes page; subsequent GET returns archived page
  • PATCH /v1/blocks/{id}/children appends a paragraph block; GET /v1/blocks/{id}/children returns it
  • POST /v1/search with query: "<page title>" finds the test page
  • Request sent without Notion-Version header returns 400 with code missing_version
  • Request to a page not shared with integration returns 404 with code object_not_found
  • Rapid requests (>180/min, or >600/min on Business/Enterprise) trigger 429; Retry-After header is present; retry succeeds
  • Payload with 101 blocks in children returns validation_error
  • Upsert pattern: create, then call upsert again with same external ID → updates, does not duplicate

Sources

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.