Skip to content

Notion (Unofficial)

notion_unofficial reads and writes Notion through its private web API (notion.so/api/v3) using a browser session cookie (token_v2) — no OAuth, no integration token. Where the official notion connector only sees what's shared to a bot integration, this one sees everything the logged-in user can see.

Best-effort / undocumented

The private API is not a public contract: it can change without notice, the cookie expires when the browser session ends, and using it is a ToS grey area. Treat this connector as a convenience, not a guarantee. Prefer the official notion connector for anything durable.

Sourceplugins/connector/notion_unofficial/
Keynotion_unofficial
Icon📓
Tierplugin — install with <app> plugin install notion_unofficial
Default tagsConnector, Productivity

See Connector Plugins for the install flow.

Importing credentials from a cURL

The connector authenticates with your browser's token_v2 cookie. The easiest way to hand it over is to copy any live notion.so/api/v3 request as a cURL and let the Import widget parse it — one paste fills TokenV2, UserAgent, NotionClientVersion, and ActiveUserID for you.

  1. Open Notion in your browser (app.notion.com / notion.so) while logged in.

  2. Open DevTools (F12 or Ctrl/Cmd+Shift+I) → Network tab.

  3. In the filter box type v3 so only private-API calls show. Click around Notion (open a page) so a request appears — e.g. getTeamsV2, syncRecordValues, loadPageChunk. Any notion.so/api/v3 request works.

  4. Right-click the request → CopyCopy as cURL (bash).

    DevTools Network tab filtered to v3, right-click a request, Copy → Copy as cURL (bash)

    Which cURL format?

    On Windows both Copy as cURL (bash) and Copy as cURL (cmd) are accepted — the widget parses both single-quote (bash) and double-quote (cmd) styles. Prefer bash when offered.

  5. In the connector's config, paste the copied cURL into the Import textarea and click Extract. The fields below fill in automatically.

  6. Confirm the Status widget shows Connected as your user + workspace.

The cookie expires

token_v2 is a session cookie: it dies when you log out, change your password, or the session rotates. When requests start returning 401 not authenticated, repeat the steps above with a fresh cURL. There is no refresh — the private API has no token-refresh flow.

You can also fill TokenV2 by hand: DevTools → Application → Cookies → https://www.notion.sotoken_v2. Import-from-cURL is preferred because it also captures the matching User-Agent and client-version headers, which makes requests blend in with your real session.

Configs

FieldRequiredNotes
Import (widget)Paste a Copy-as-cURL of any notion.so/api/v3 request from browser DevTools, click Extract — it parses the curl and fills the fields below automatically. See Importing credentials from a cURL. The easiest way to set this connector up.
TokenV2✅ (secret)The token_v2 cookie from a logged-in notion.so session (DevTools → Application → Cookies → token_v2). Filled by Extract, or paste manually. Expires when the browser session ends.
ActiveUserIDSent as x-notion-active-user-header. Filled by Extract; needed on sessions with multiple Notion accounts.
UserAgentAdvanced. Browser User-Agent sent with every request. Filled by Extract from a Copy-as-cURL — matches your real session so requests blend in.
NotionClientVersionAdvanced. Notion-Client-Version header the web app sends. Filled by Extract; defaults to a sensible value.
Status (widget)Live connection status card — probes the cookie and shows the logged-in user + workspace.

All four auth/advanced fields are required — but the Import widget fills them all in one paste, so setup is still a single click, not extra manual typing.

Beyond these config fields, this connector also requires the per-instance AI description to be filled before it counts as set up — see AI description required.

AI description required

TokenV2 is a personal Notion session cookie — every call through this connector acts as that one human and can see/modify everything their account can, across their whole workspace. An instance with no record of who's allowed to use it is a liability, so this connector makes the per-instance AI description mandatory.

The AI description is the free-text guidance an admin writes for the LLM (shown in the connector's detail page). For notion_unofficial it doubles as the record of who may use this instance and what for. The agent sees it as a distinct operator_note field in wick_list / wick_search / wick_get — separate from the connector's built-in description — so it can tell an operator instruction from product copy and weight it accordingly.

  • While the AI description is blank, the instance reports as needs_setup — exactly like a missing required config field. It shows as unfinished in the manager UI and is treated as not-ready everywhere its status is consulted.
  • Fill the AI description (state who's allowed to use it, and optionally what for) and the instance flips to ready.
  • The Import and Status widgets work regardless, so an operator can paste the token and confirm the connection first, then write the AI description to finish setup.

Setup — how to use it

End-to-end, from a fresh install to an agent making calls:

  1. Install & enable the plugin: <app> plugin install notion_unofficial, then enable it (see Connector Plugins).
  2. Hand over credentials. In the connector's config, paste a Copy-as-cURL into the Import widget and click Extract — see Importing credentials from a cURL. This fills TokenV2 and friends.
  3. Confirm the connection. Check the Status widget reads Connected as your user + workspace. (Both widgets work before the instance is enabled.)
  4. Fill the AI description. On the connector's detail page, write who's allowed to use this instance (and optionally what for). This is required — until it's filled, the instance stays needs_setup; once filled, it's ready. See AI description required.
  5. Use it from an agent. The ops are now callable. A typical flow: describe_database (get property names/types/options) → create_page to add a row, or update_page_properties to change an existing row's cells; list_blocksupdate_block/delete_block/append_content to edit page body.

Operations

Read

OpInputWhat it does
fetchpage_idDownload a page and render its whole body as markdown (block tree recursed) — the MCP-style single-call read. Embedded databases (inline and linked views) are expanded into markdown tables that respect the view's filter, sort, and visible columns; dates, people, and relations resolve to readable values. Each table gets a footer noting the source database ID and view filter.
query_databasepage_id, limitReturn a database's rows as {id, title, cells}, applying the view's filter and sort.
describe_databasepage_idReturn the schema: every property's {name, type, writable, options}, plus the view's filter. Call this before create_page on a database to get exact property names, types, select options, and which property a new row must set to appear in a filtered view.
get_recordsidsFetch raw block records by comma-separated ID — an escape hatch when fetch/query_database don't expose a field.
list_blockspage_idList a page's top-level content blocks in order, each as {id, type, text, editable}. Call this before update_block/delete_block to get the ID of the exact block to change. editable: false marks blocks whose text can't be rewritten in place (images, embeds, tables, dividers, …).

Write

OpInputWhat it does
create_pageparent_type, parent_id, title, propertiesCreate a subpage under a page, or add a row to a database (parent_type=database). For a row, properties is JSON name → value; call describe_database first for the exact names/types. Returns {id, url} (+ skipped_properties for any unknown/read-only names).
create_commentpage_id, textAdd a page-level comment to a page or a database row (a row is a page). Comments are append-only — no edit/delete-comment op.
set_titlepage_id, titleRename a page (the H1 / row Name). Does not touch the body.
append_contentpage_id, markdown, after_block_idAdd new blocks from markdown. By default they go at the end; set after_block_id (from list_blocks) to insert right after that block — i.e. in the middle of the page. Existing content is never touched. Returns {page_id, added, block_ids}.
update_blockblock_id, text, typeRewrite one block's text in place, addressed by its ID from list_blocks — every other block stays exactly as-is. Optionally set type to convert the block (e.g. text → sub_header). Refuses non-text blocks. Returns {id}.
delete_blockpage_id, block_idRemove one block by its ID (from list_blocks). Only that block goes; the rest of the page stays. Returns {id, deleted}.
update_page_propertiespage_id, propertiesEdit the property cells of an existing database row in place (status, date, select, relation, checkbox, …) — only the listed properties change, every other cell and the row's body content is left exactly as-is. properties is JSON name → value, same shapes as create_page; call describe_database first for the exact names/types/options. Refuses a plain page (no property schema — use set_title/update_block there). Returns {id, updated, skipped_properties}.

Read ops never mutate; only the write ops above call the private API's saveTransactions endpoint.

Editing page content in place

To change something already on a page without rewriting the whole page, always work per-block:

  1. list_blocks → find the block you want by its text/type, note its id, and check editable.
  2. Then one of:
    • Fix / rewrite that block → update_block with its id (only that block changes).
    • Remove it → delete_block with its id.
    • Insert near itappend_content with after_block_id = its id.

append_content's markdown supports #/##/### headings, - / 1. / - [ ] lists, > quotes, fenced code, and --- dividers. Inline **bold** / `code` / links are stored as plain text.

Two guards keep an edit from breaking the page:

  • update_block refuses non-text blocks. Images, embeds, tables, dividers, and page links have no editable title — list_blocks marks them editable: false and update_block returns an error naming the editable types. Pick an editable: true block.
  • append_content validates after_block_id. An anchor that isn't a top-level block of the target page is rejected (instead of silently mis-placing the new blocks). Always pass an ID that list_blocks returned for that page.

Writing database row properties

Use create_page to set properties on a new row, or update_page_properties to change property cells on an existing row (only the listed cells change; the rest of the row and its body are untouched). Both take the same properties shape.

properties values are plain strings in a format-per-type convention:

  • select — exact option name
  • multi_select — comma-separated option names
  • checkboxtrue / false
  • dateYYYY-MM-DD or YYYY-MM-DD HH:MM, range with
  • relation / person — comma-separated IDs
json
{
  "Activity": "Debug",
  "Start time": "2026-07-17 06:00",
  "End time": "2026-07-17 07:00",
  "Ticket": "<host-page-id>"
}

To make a new row show up in a filtered view, set the property named in describe_database's view_filter (e.g. a relation pointing at the host page). Notion re-indexes within a few seconds.

Known limitations

  • Formula and rollup columns are blank. Notion computes them server-side and doesn't store them on the row.
  • Relation writes are one-directional. Setting a relation property makes the row match a relation_contains filter after Notion re-indexes; the dual back-reference is left to Notion itself.
  • No comment on a sub-range of text — comments anchor to a page/block, same as the official connector.
  • Block editing is top-level only. list_blocks, update_block, and delete_block operate on a page's direct children. Blocks nested inside a toggle or a column are not enumerated, so they can't be targeted by ID yet.
  • append_content stores plain text. Inline markdown (**bold**, `code`, links) inside a line is not parsed into rich text — it's kept as literal characters within the block.

See also

  • Notion — the official REST API connector. Prefer it when the content only needs a bot-shared page/database and durability matters more than reach.
  • Connector Module — module contract, per-instance AI description.
  • Connector Plugins — install / update / uninstall flow.
Built with ❤️ by a developer, for developers.