Skip to content

Notion API Playbook

Canonical reference for editing Justin's Notion workspace from code or CLI. Any forge agent (brain, cron, tmux worker, /spawn'd Claude) should consult this doc before rolling its own Notion HTTP.

The single tool: forge/scripts/forge_notion_api.py. It wraps both Notion APIs behind one client class. Use it from Python (from forge_notion_api import NotionAPI) or as a CLI.

Architecture

Notion has two API surfaces. Forge talks to both.

API Auth What it's for Risk
Public API (api.notion.com/v1) Integration token (Bearer) Row CRUD, page create/update/archive, DB schema, search, comments, icons, covers Officially supported; stable
Internal API (notion.so/api/v3) Session cookie (token_v2) Linked database views, Favorites, block reorder, soft-delete, internal record reads Undocumented; can change

Decision rule: prefer public. Drop to internal only when public can't do it.

Need API Method
Create row in DB public page_create(parent_type='database_id', ...)
Update row property public page_update(page_id, properties=...)
Query DB rows public database_query(db_id, filter=..., sorts=...)
Add new property to DB public database_update(db_id, properties=...)
Add a new DB public database_create(parent_page_id, title, properties)
Archive a page or DB public page_archive(page_id) / database_update(archived=True)
Set page icon (static) public set_icon(record_id, emoji=...)
Set page cover image public set_cover(record_id, image_url)
Search by title public search(query, filter_type='page')
Add comment to page public comment_create(parent_page_id, text)
List comments public comment_list(block_id)
Get block children public block_children(block_id)
Append blocks to page public block_append_children(block_id, children)
Update existing block content public block_update(block_id, paragraph={...})
Create linked database view internal create_linked_view(page_id, db_id, ...)
Configure view filter/sort/group/layout internal configure_view(view_id, ...)
Pin page to Favorites internal favorite_page(page_id)
Move existing block (non-destructive) internal move_block(block_id, parent_page_id, after_block_id=...)
Set default view of a DB internal set_default_view(db_block_id, view_id)
Inspect internal records (block / collection / view) internal get_record(table, id)

Auth

~/.forge-secrets/notion-cookie.env (chmod 600):

NOTION_TOKEN_V2=...           # session cookie, internal API; expires ~6mo
NOTION_INTEGRATION_TOKEN=...  # bearer, public API; long-lived
NOTION_USER_ID=...            # uuid
NOTION_SPACE_ID=...           # workspace uuid
[email protected]

Symptom: any internal-API call returns 401 / 403.

Fix: 1. On Mac, open https://www.notion.so in a browser 2. DevTools (Cmd+Opt+I) → Application → Cookies → notion.so → token_v2 3. Copy the value (long string starting with v03%3A...) 4. Update NOTION_TOKEN_V2=<value> in ~/.forge-secrets/notion-cookie.env 5. Done. No restart needed; next call uses the new value.

Integration token rotation (rare)

If the public API token (ntn_...) ever rotates: 1. Notion → Settings → Integrations → find the "n8n Forge" integration 2. Regenerate token, copy 3. Update NOTION_INTEGRATION_TOKEN=<value> in the same env file 4. Also update n8n credential mgQHSiQOlbURyLs1 (Settings → Credentials → Notion)

Quick CLI reference

# Inspection
forge_notion_api.py search "JWVR" --type page --limit 5
forge_notion_api.py page-get <page_id>
forge_notion_api.py db-get <db_id>
forge_notion_api.py db-query <db_id> --limit 5
forge_notion_api.py block-children <page_id>
forge_notion_api.py list-views <page_id>
forge_notion_api.py schema <collection_id>
forge_notion_api.py resolve-collection <db_id>
forge_notion_api.py show <id> --table block|collection|collection_view

# Public-API mutations
forge_notion_api.py page-create --parent-type page_id --parent-id <p> --title "New Page" --icon 🎯
forge_notion_api.py page-update <page_id> --title "Renamed" --icon forge_notion_api.py page-archive <page_id>
forge_notion_api.py set-icon <record_id> --emoji 📥
forge_notion_api.py set-cover <record_id> --image https://...
forge_notion_api.py block-delete <block_id>
forge_notion_api.py comment-add <page_id> "Reviewed and approved"
forge_notion_api.py comment-list <block_id>

# Internal-API mutations
forge_notion_api.py create-view <page_id> --db <db_id> --name "Today" --type board --filter '{...}' --group-by '<prop_id>'
forge_notion_api.py configure-view <view_id> --type board --filter '{...}' --sort '[...]'
forge_notion_api.py rename-view <view_id> "New Name"
forge_notion_api.py move-block <block_id> --parent <page_id> --after <other_block_id>
forge_notion_api.py soft-delete <block_id>
forge_notion_api.py favorite <page_id>
forge_notion_api.py unfavorite <page_id>

Library use (from Python)

import sys
sys.path.insert(0, "/home/justinwieb/forge/scripts")
from forge_notion_api import NotionAPI

api = NotionAPI()

# Public: create a row in a DB
api.page_create(
    parent_type="database_id",
    parent_id="<life_tasks_id>",
    properties={
        "Name": {"title": [{"type": "text", "text": {"content": "Buy lens"}}]},
        "Priority": {"select": {"name": "Medium"}},
    },
)

# Public: search
hits = api.search(query="JWVR", filter_type="page", page_size=10)

# Internal: configure a view
api.configure_view(
    view_id="<view_id>",
    type="board",
    name="Life Tasks today",
    group_property="<status_prop_id>",
    filter={
        "filters": [{
            "property": "<scheduled_pid>",
            "filter": {"operator": "date_is_on_or_before", "value": {"type": "relative", "value": "today"}}
        }],
        "operator": "and"
    },
)

Recipes

Recipe: add a filtered linked view to a page

api = NotionAPI()
HQ = "<hq_page_id>"
LIFE_TASKS = "<life_tasks_db_id>"

# Resolve property short-IDs (collection schemas use short codes, not names)
coll_id = api.get_collection_id_for_db(LIFE_TASKS)
schema = api.get_collection_schema(coll_id)
status_pid = next((p for p, d in schema.items() if d.get("name") == "Status"), None)
scheduled_pid = next((p for p, d in schema.items() if d.get("name") == "Scheduled"), None)

# Get the last block on HQ to insert after
hq = api.get_record("block", HQ)
last_block = (hq.get("content") or [None])[-1]

api.create_linked_view(
    page_id=HQ,
    source_db_id=LIFE_TASKS,
    view_name="Life Tasks today",
    view_type="board",
    after_block_id=last_block,
    filter={
        "filters": [
            {"property": scheduled_pid, "filter": {"operator": "date_is_on_or_before",
                "value": {"type": "relative", "value": "today"}}},
            {"property": status_pid, "filter": {"operator": "status_does_not_equal",
                "value": {"type": "exact", "value": "Done"}}},
        ],
        "operator": "and",
    },
    group_property=status_pid,
)

Recipe: pin a page to Favorites

api.favorite_page(page_id)

Recipe: bulk archive empty rows

res = api.database_query(db_id, filter={"property": "Notes", "rich_text": {"is_empty": True}}, page_size=100)
for row in res["results"]:
    api.page_archive(row["id"])

Recipe: create a sub-page with structured content

api.page_create(
    parent_type="page_id",
    parent_id=parent_id,
    title="Trip Plan: Italy 2026",
    icon="✈️",
    children=[
        {"object": "block", "type": "heading_1",
         "heading_1": {"rich_text": [{"type":"text","text":{"content":"Italy 2026"}}]}},
        {"object": "block", "type": "paragraph",
         "paragraph": {"rich_text": [{"type":"text","text":{"content":"7 nights, Rome + Florence + Cinque Terre"}}]}},
    ],
)

Recipe: move a block to the top of a page

api.move_block(block_id, parent_page_id, before_block_id=current_first_block_id)

Recipe: search and tag

hits = api.search(query="VR review", filter_type="page", page_size=20)
for hit in hits.get("results", []):
    api.page_update(hit["id"], properties={"Tags": {"multi_select": [{"name": "vr-review-2026"}]}})

Filter format reference

Public API filter (use with database_query)

Format:

{
  "property": "Sorted?",
  "checkbox": {"equals": false}
}

Compound:

{
  "and": [
    {"property": "Status", "status": {"does_not_equal": "Done"}},
    {"property": "Due", "date": {"on_or_before": {"now": {}}}}
  ]
}

Internal API filter (use with configure_view, create_linked_view)

Critical: properties referenced by their short ID (e.g. {oig), not their human name. Use get_collection_schema(collection_id) to map name → ID.

Format:

{
  "filters": [
    {
      "property": "{oig",
      "filter": {
        "operator": "checkbox_does_not_equal",
        "value": {"type": "exact", "value": true}
      }
    }
  ],
  "operator": "and"
}

Common operators: | Type | Operator | value shape | |---|---|---| | Checkbox | checkbox_is, checkbox_does_not_equal | {"type":"exact","value": true|false} | | Date | date_is, date_is_on_or_before, date_is_on_or_after, date_is_after, date_is_before | {"type":"relative","value":"today"} or {"type":"exact","value":"<ISO date>"} | | Status | status_is, status_does_not_equal | {"type":"exact","value":"<option name>"} | | Select | enum_is, enum_does_not_equal | same | | Multi-select | enum_contains, enum_does_not_contain | same | | Text | string_contains, string_does_not_contain, string_starts_with, string_ends_with, string_is_empty | {"type":"exact","value":"..."} | | Number | number_is, number_greater_than, number_less_than | {"type":"exact","value": 5} | | Relation | relation_contains, relation_does_not_contain | {"type":"exact","value":"<related page id>"} | | Person | person_contains, person_does_not_contain | {"type":"exact","value":"<user id>"} |

Sort format reference

Public API

[{"property": "Priority", "direction": "ascending"}]

Internal API (in query2.sort)

[
  {"property": "<priority_pid>", "direction": "ascending"},
  {"property": "<due_pid>", "direction": "ascending"}
]

Layout / view-type reference

Internal API view type: - table — rows + columns - board — Kanban (requires format.board_columns_by) - calendar — by date property (requires format.calendar_by) - gallery — cards (cover image-friendly) - list — minimal list - timeline — Gantt-style by date range

Board grouping (set with group_property arg):

{
  "format": {
    "board_columns_by": {
      "property": "<status_pid>",
      "type": "select",
      "sort": {"type": "manual"},
      "hideEmptyGroups": false,
      "disableBoardColorColumns": false
    }
  }
}

Calendar layout:

{
  "format": {
    "calendar_by": "<date_pid>"
  }
}

Property ID resolution (internal API)

Internal API uses short codes for property IDs (e.g. {oig, =fEa, Rvx;). Public API uses property names. To go from name → ID:

schema = api.get_collection_schema(collection_id)
prop_id = next((p for p, d in schema.items() if d.get("name") == "Sorted?"), None)

The title field is always "title" (special case).

Page ID vs Collection ID gotcha

When the public API returns a "database id" (the value used in database_query, database_update, etc.), that's actually the collection_view_page block id. The underlying collection id (used in internal API format.collection_pointer) is different.

To translate:

collection_id = api.get_collection_id_for_db(public_db_id)

The tool handles this automatically in create_linked_view.

Rate limits

  • Public API: 3 requests/sec average (Notion documented)
  • Internal API: not documented; assume similar; throttle bulk ops

For bulk operations (e.g. update 200 pages), space requests by ~350ms or batch via transact() for the internal API (multiple operations in one HTTP call).

Troubleshooting

Symptom Cause Fix
401 Unauthorized on internal calls token_v2 cookie expired Re-extract from DevTools, update env file
401 Unauthorized on public calls Integration token wrong or revoked Regenerate via Notion → Settings → Integrations
400 Validation error on filter Filter shape wrong (public vs internal mixed up) Check which API the method uses, match shape
404 not found on a page that exists Integration not shared with that page tree Notion app → page → ⋯ → Add connections → n8n Forge
409 Conflict on transact Concurrent edit Retry; consider re-fetching version first
Property X not found Used name instead of short-id in internal filter Use get_collection_schema to map name → id
Linked view shows "Untitled" with no rows format.collection_pointer.id is wrong (used block id instead of collection id) Use get_collection_id_for_db() to translate

Operations the tool DOES NOT yet expose

Want Status Workaround
Set page icon driven by a property formula Notion doesn't support dynamic icons natively Cron polls property + calls set_icon
Bulk page create Not built Loop page_create, throttle to 3/sec
Workspace-level settings (membership, billing) Likely scope-limited even in internal API Manage in Notion app
Cross-workspace operations Both APIs are single-workspace-scoped Use multiple NotionAPI instances
Block-level edit history / versioning Not exposed Manually track in Notion's UI

Doctrine for forge agents

If you're a Claude session adding new Notion functionality:

  1. Use forge_notion_api.py. Don't roll your own HTTP.
  2. Prefer public API unless the operation is in the internal-only list above.
  3. Add new methods to the NotionAPI class in forge_notion_api.py rather than spreading Notion logic across multiple files.
  4. Add a CLI subcommand for any new method so it's reachable from a tmux shell.
  5. Update this playbook when adding capabilities. Future Claudes will read it cold.
  6. Don't store credentials anywhere except ~/.forge-secrets/notion-cookie.env.

See also