Skip to main content
35 toolsOAuth bearer
notion_example.py
Notion is two APIs wearing one coat. There is a document API, where a page is a tree of blocks, and a database API, where a page is a row and its properties are columns. The same pages_create call does both, and which one you get depends on the parent you give it.

A database is not the table

Since Notion’s 2025-09-03 version a database is a container and a data source is the table inside it. The columns belong to the data source, rows are queried from it, and a page created inside a database takes a data_source_id as its parent. That matters for the first call you make. databases_retrieve returns the data sources a database holds; data_sources_retrieve returns the column schema you have to write against. Querying rows or changing columns through a database ID is the older shape, which still answers for a database with one table and fails for one with several.

Authenticating

This pack takes an OAuth bearer token, and configure() is optional when $NOTION_API_KEY is set. Which credential provider you hand it depends on whose account the calls run as.

A token you hold

For a script, or a notebook. EnvTokenProvider re-reads the variable on every call, so a token rotated beside the process is picked up without a restart; StaticTokenProvider takes one you already hold as a string. Neither renews anything, so the calls stop when the token expires.
notion_script.py

One account, refreshed

For an agent or a server acting as you. OAuth2Client turns a client registration and a stored refresh token into an access token, and renews it before it lapses.
notion_agent.py
No refresh token yet? Your own account is the one-time consent flow that hands you one.

Many end users

For a product whose users each connect their own account. SubjectProvider builds one credential per user through a factory you write, and use_subject names the user a call acts for. Your users’ accounts is the consent route inside your app; serving many users is the per-subject cache and its eviction.
notion_server.py
Connecting a workspace you do not own is an OAuth flow rather than a pasted secret. Using your own account covers the single-token case, which is what an internal integration is. An internal integration secret is prefixed ntn_ and goes out as a bearer token. An OAuth access token works the same way, and so does a personal access token, with one difference worth knowing: users_list refuses a personal access token. Notion’s permissions are not scopes you request per call. An integration is given capabilities in Notion’s own settings, and each tool page names the one it needs by the label you tick there. An integration also only sees pages somebody has shared with it. A page that has not been shared is a 404, not a 403, so an empty search usually means nothing has been shared rather than that nothing exists.

The client

oauth_tool_factory is the whole client: a thin wrapper over httpx that attaches your token and these endpoint constants to each request. No vendor SDK enters your dependency tree.
credentials is whichever of the three you built in Authenticating. A pack takes it through configure(); a client you build takes the same object as credential_provider, and has no configure() of its own.

Paging through a list

A cursor belongs to the tool that returns it, so it is declared on that tool’s builder call:
notion_pagination.py
Pagination is declared on users_list, pages_retrieve_property_item, blocks_children_list, data_sources_templates_list, comments_list, file_uploads_list, custom_emojis_list, search and data_sources_query. The other 26 take no cursor.
Notion-Version is required on every request. Omit it and the answer is 400 missing_version, so there is no unpinned mode to fall back to. The pack sends 2026-03-11, which is the version that renamed archived to in_trash and replaced block append’s after parameter with position.

Tools

Each is a Tool, called with ainvoke as in the snippet above. The name links to its parameters, its response and what it costs.
Usersusers_listGETList the people and bots in the workspace.users_retrieveGETGet one user by ID.users_retrieve_meGETGet the bot this token authenticates as, including which workspace it is in.SearchsearchPOSTSearch the titles of pages and data sources shared with this integration.Pagespages_createPOSTCreate a page — as a subpage of another page, or as a row of a database by giving its data_source_id as the parent.pages_retrieveGETGet a page’s properties.pages_updatePATCHUpdate a page’s property values, icon, cover, or trash state.pages_movePOSTMove a page under a different parent page or data source.pages_retrieve_property_itemGETRead one property of a page in full.pages_retrieve_markdownGETGet a page’s whole content as Markdown, rendered by Notion.pages_update_markdownPATCHReplace a page’s content with Markdown, which Notion parses into blocks.Blocksblocks_retrieveGETGet one block.blocks_updatePATCHUpdate a block’s content, or trash and restore it with in_trash.blocks_deleteDELETEMove a block to the trash.blocks_children_listGETList the direct children of a block, or of a page when given a page ID.blocks_children_appendPATCHAppend blocks to a page or a block.Databasesdatabases_createPOSTCreate a database on a page or at the top of the workspace.databases_retrieveGETGet a database and the data sources it contains.databases_updatePATCHUpdate a database’s title, description, icon, cover, inline display or trash state, or move it to a new parent.Data sourcesdata_sources_createPOSTAdd another table to an existing database.data_sources_retrieveGETGet a data source’s column schema — the names, types and select options a write has to match.data_sources_updatePATCHChange a data source’s columns, title or icon.data_sources_queryPOSTGet the rows of a data source, optionally filtered and sorted.data_sources_templates_listGETList the page templates defined on a data source.Commentscomments_createPOSTComment on a page or block, or reply to an existing discussion with its discussion_id.comments_listGETList the unresolved comments on a page or block.comments_retrieveGETGet one comment by ID.comments_updatePATCHEdit a comment’s content, replacing it entirely.comments_deleteDELETEDelete a comment.File uploadsfile_uploads_createPOSTStart a file upload.file_uploads_completePOSTFinish a multi-part upload once every part has been sent.file_uploads_retrieveGETGet a file upload and its status.file_uploads_listGETList this integration’s file uploads, optionally by status.Custom emojiscustom_emojis_listGETList the workspace’s own uploaded emoji.Async tasksasync_tasks_retrieveGETCheck work Notion took in the background.

Reading a page

There are three ways, and they cost very different amounts. pages_retrieve returns the properties and none of the content. For a row of a database that is the whole story, and it is the cheap call. pages_retrieve_markdown returns the content as Markdown, rendered by Notion. One call, no recursion, and the result reads like a document. Reach for it whenever the goal is to understand a page rather than edit a specific block. blocks_children_list returns the blocks, one level at a time. A child that reports has_children needs another call with its own ID. Use it when you intend to edit, since only blocks have the IDs blocks_update needs.

Writing content

Notion accepts at most 100 blocks and two levels of nesting in one request, and the schema says so rather than leaving it to a 400. The block model is three tiers: a top-level block holds nested blocks, a nested block holds leaf blocks, and a leaf block holds nothing. A column and a column_list only exist at the top; a table cannot be a leaf, because a table is its rows. Deeper trees are built by appending to the blocks that come back. The same limit is why pages_create takes a markdown string as an alternative. Notion parses it server-side into blocks, at any depth, which is the shortest path from a document you already have to a page that holds it.

Querying a database

data_sources_query takes a filter built from one condition per column type, composed with and and or. Both are Python keywords, so the fields are spelled and_ and or_ in Python and go out under Notion’s own names:
A filter naming a column that does not exist is a 400, so read the schema with data_sources_retrieve first when you do not already know the column names.

What the response handlers drop

Notion has no plain strings. Every piece of text is an array of runs, and each run carries its annotations, a plain_text copy, an href and a type tag, so a one-line title is about 200 bytes of JSON for about 20 characters of title. A row repeats that per column and adds two user objects, two timestamps, a parent and two URLs. Every read tool projects: a run array becomes the string it spells, a property becomes its value, a select becomes its name. Nothing is filtered, because a shorter page would corrupt the length a paginated walk depends on, and every id is kept, because that is what the next call needs. data_sources_retrieve is the exception that proves it. There the schema is the payload, so the columns are kept in full and only the per-option IDs and colours go.

Known limits

Sending file bytes is not a tool. A file becomes Notion-hosted content in three steps, and the middle one takes multipart/form-data with raw bytes, which Charter does not encode and which a model has no way to supply. The other four upload endpoints are here, and mode="external_url" completes without that step: Notion fetches the file from a public URL itself. Coverage is the content API. Users, pages, blocks, databases, data sources, search, comments, file uploads, custom emojis and async tasks. Notion’s agent and session platform, the Views API and meeting notes are not modelled, and neither are the OAuth token endpoints, which belong to an authorization server rather than to a tool. pages_create carries a large schema. Every block type is reachable from it, which is 87KB of JSON schema. That is the cost of being able to write any block rather than the popular six. Narrow it at the point of use with Tool.derived when an agent only needs a few.