← Back to Tool Reference

Notion — Tool Reference

Read-only in this release. Search pages and databases by title, read pages as Markdown, inspect database schemas, query data sources, read individual page properties past Notion's 25-reference limit, and read comments.

12 tools · auto-generated from the deployed toolkit

Your AI assistant discovers and invokes these tools through the MCP Server's meta tools — it calls execute_tool with the tool name and arguments below.

# append_content write

Add Markdown content to a Notion page at the end, the start, or after existing text. Does not replace anything.

Full description
Insert Markdown into a Notion page without replacing what is there.

`PATCH /v1/pages/{page_id}/markdown` with the `insert_content` command
[verified — developers.notion.com/reference/update-page-markdown]. This tool
only ever ADDS: no call to it removes or rewrites existing content.

`position` is this toolkit's own stable parameter, not Notion's shape.
Notion has two mutually exclusive fields — a `position` of `start` or `end`
(those are its only two values), and an `after` selection — and combining
them is a documented error. Mapping one parameter onto them happens in one
adapter, so this schema stays put if the provider's command changes; Notion
already describes `insert_content` as "still supported but no longer
recommended" [verified, same page].

`after_text` uses Notion's ellipsis format [verified, same page: "Selection
of existing content to insert after, using the ellipsis format ('start
text...end text')"], so a long span can be selected by its first and last
few words rather than quoted in full.

The response is compact by design. Notion answers this endpoint with the
page's entire Markdown; returning that after a small insertion would be this
toolkit's largest response, for content `get_page` already provides. What is
kept is the resulting length and Notion's own truncation flag. Notion
canonicalizes Markdown as it stores it, so what `get_page` returns is not
always the Markdown sent here — a pipe table comes back as
`<table header-row="true">` HTML — and `update_page_content`'s `find`
matches that stored form rather than what was sent.

Notion provides no concurrency control on this endpoint — no ETag, no
version precondition, no compare-and-swap. Insertion is the mode least
exposed to that (it adds rather than overwrites), but `after_text` still
selects against content that may have changed since you read it, in which
case the insert lands somewhere you did not intend or fails to match at all.
Parameter Type Required Description
page_id string Yes Page id or Notion URL.
markdown string Yes Notion-flavored Markdown to insert into the page.
position "end" | "start" | "after" No Where the content goes. `end` appends, `start` prepends, `after` inserts after existing text you identify with `after_text`. Default: "end"
after_text string | null No Required when position is `after`, and forbidden otherwise. Identifies existing page content using Notion's ellipsis format: either the exact text to insert after, or 'first few words...last few words' to select a longer span without quoting all of it.
Parameter schema (JSON)
{
  "properties": {
    "page_id": {
      "description": "Page id or Notion URL.",
      "title": "Page Id",
      "type": "string"
    },
    "markdown": {
      "description": "Notion-flavored Markdown to insert into the page.",
      "minLength": 1,
      "title": "Markdown",
      "type": "string"
    },
    "position": {
      "default": "end",
      "description": "Where the content goes. `end` appends, `start` prepends, `after` inserts after existing text you identify with `after_text`.",
      "enum": [
        "end",
        "start",
        "after"
      ],
      "title": "Position",
      "type": "string"
    },
    "after_text": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Required when position is `after`, and forbidden otherwise. Identifies existing page content using Notion's ellipsis format: either the exact text to insert after, or 'first few words...last few words' to select a longer span without quoting all of it.",
      "title": "After Text"
    }
  },
  "required": [
    "page_id",
    "markdown"
  ],
  "title": "AppendContentParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "description": "The compact confirmation every content write returns.",
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "applied": {
      "default": true,
      "description": "True on every returned result. A content write that did not take effect raises an error instead of returning `applied: false`, so this is a restatement for a transcript, not a field to branch on.",
      "title": "Applied",
      "type": "boolean"
    },
    "content_length_chars": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Length of the page's Markdown AFTER the write, measured from the response Notion returns. Null if Notion sent no markdown back. The full text is deliberately not returned — use get_page for it.",
      "title": "Content Length Chars"
    },
    "content_truncated": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own `truncated` from the response, verbatim: the page now exceeds Notion's record limit (approximately 20,000 blocks) and a later read of it will be partial. Null when Notion did not report.",
      "title": "Content Truncated"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "ContentWriteResult",
  "type": "object"
}

# create_comment write

Comment on a Notion page or reply to a thread. Comments cannot be edited or deleted; requires insert-comment capability.

Full description
Add a comment to a Notion page, or reply to an existing discussion.

`POST /v1/comments` [verified —
developers.notion.com/reference/create-a-comment]. The text is sent as
Markdown, which that endpoint accepts directly: "The content of the comment
as a Markdown string. Comment Markdown supports inline formatting only
(bold, italic, strikethrough, code, links), inline equations ($expression$),
and mentions." [verified].

**Exactly one target.** [verified, same page: "Exactly one of
`parent.page_id`, `parent.block_id`, or `discussion_id` must be provided."]
This tool exposes two of the three: `page_id` starts a new thread, and
`discussion_id` — from `get_comments` — replies to an existing one.
`block_id` is deliberately not offered, because nothing in this toolkit
hands out block ids to target with.

**Commenting is a separate Notion capability from reading or writing
content.** [verified, same page: "Attempting to call this endpoint without
insert comment capabilities will return an HTTP response with a 403 status
code."] A 403 here while other tools work means that capability is missing;
it is enabled on the integration in Notion, then reconnected in the
Toolforest portal.

Comments cannot be edited or deleted through this API, so a comment created
here is permanent as far as this toolkit is concerned.
Parameter Type Required Description
page_id string | null No Start a NEW comment thread on this page. Id or Notion URL. Mutually exclusive with discussion_id.
discussion_id string | null No Reply to an EXISTING thread, as returned by get_comments. Mutually exclusive with page_id.
markdown string Yes The comment text as Markdown. Inline formatting only — bold, italic, strikethrough, code, links, inline equations and mentions. Block-level Markdown (headings, lists, tables) is not supported in a comment.
Parameter schema (JSON)
{
  "properties": {
    "page_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Start a NEW comment thread on this page. Id or Notion URL. Mutually exclusive with discussion_id.",
      "title": "Page Id"
    },
    "discussion_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Reply to an EXISTING thread, as returned by get_comments. Mutually exclusive with page_id.",
      "title": "Discussion Id"
    },
    "markdown": {
      "description": "The comment text as Markdown. Inline formatting only — bold, italic, strikethrough, code, links, inline equations and mentions. Block-level Markdown (headings, lists, tables) is not supported in a comment.",
      "minLength": 1,
      "title": "Markdown",
      "type": "string"
    }
  },
  "required": [
    "markdown"
  ],
  "title": "CreateCommentParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "properties": {
    "comment_id": {
      "title": "Comment Id",
      "type": "string"
    },
    "discussion_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The thread this comment belongs to. Pass it back as discussion_id to reply again in the same thread.",
      "title": "Discussion Id"
    }
  },
  "required": [
    "comment_id"
  ],
  "title": "CreateCommentResult",
  "type": "object"
}

# create_database write

Create a Notion database with a column schema. Returns the data_source_id that create_page and query_data_source need.

Full description
Create a Notion database under a page, with a normalized column schema.

`POST /v1/databases` [verified —
developers.notion.com/reference/database-create]. The schema rides in
`initial_data_source.properties`, NOT in a top-level `properties` — that
moved in the 2025-09-03 data-source model, and the old shape is the single
most likely thing to get wrong here [verified —
developers.notion.com/guides/get-started/upgrade-guide-2025-09-03, which
gives the before/after request bodies side by side]. `title`, `description`
and `is_inline` stay top-level [verified, same reference page].

**A database is a container; rows live in a DATA SOURCE inside it.** The
response carries both — `id` for the database and `data_sources[0].id` for
the data source it created [verified, same reference page] — and this tool
returns both because they are not interchangeable. `create_page` and
`query_data_source` take the `data_source_id`; passing the `database_id`
where a data source is wanted is the other likely mistake.

**The chain this exists for**: `create_database` -> `create_page(
parent_data_source_id=...)` -> `query_data_source`, with no hand-built
database in between.

**Exactly one `title` property, checked here.** Notion requires a data
source to have precisely one, and this is verified locally before any
request is issued, so a schema with none or two is refused by an error that
names the offending properties rather than by Notion's 400, which does not.
The database's `title` argument is its NAME and is a different thing from
the title PROPERTY; both are needed.

**`status` IS built from `properties`, and its one restriction is
`group`.** Notion's create request accepts `status` and documents its
options — `status.options` takes `{name, color, description, group}`
entries [verified — same reference page], and the property documentation
says "When creating or updating custom options, pass `group` on each
option" [verified — developers.notion.com/reference/property-object]. This
was declined here until its live contract test had run; that test has now
run against dev and test [observed, #2047] and found: options carrying a
`group` land in that group, a bare `{"type": "status"}` is auto-scaffolded
by Notion into its default template (`Not started` / `In progress` /
`Done`, all three groups wired), and the result is settable through this
toolkit's normalized value side without a UI visit.

What remains restricted is the GROUP STRUCTURE, which is UI-only: `group`
is an enum of Notion's three built-ins — `To-do`, `In progress`,
`Complete` — no other group can be created, groups "cannot be reconfigured
via the API; use the Notion UI instead", and an existing option's name or
colour cannot be changed afterwards [verified —
developers.notion.com/reference/update-data-source-properties]. A `group`
outside those three is therefore refused HERE, by an error naming all
three, rather than passed through the way `color` is: this tool cannot
amend a schema once created, so a wrong group is permanent and Notion's
own 400 does not say what was allowed.

**`relation` targets a DATA SOURCE.** Its config takes `data_source_id`;
Notion no longer accepts a `database_id` on writes, though it still returns
both [verified — upgrade-guide-2025-09-03: "You can no longer provide a
`database_id`"]. Only single-property relations are built here; a
dual-property relation also writes a synced column into the target data
source, which is a second schema change to a resource this call did not
name, so it goes through `raw_properties` rather than happening quietly.

**`raw_properties` is OUTSIDE the normalized contract, deliberately.**
Whatever it carries is sent to Notion unvalidated and unchanged, so it is
bound by Notion's rules rather than by this tool's, and two consequences are
worth stating plainly rather than leaving to be discovered: a raw `status`
definition carrying options DOES create those options, and a raw DUAL
relation ALSO adds a synced property to the TARGET data source — a schema
write to a resource outside the page named in `parent_page_id`. Nothing here
inspects raw definitions to prevent either; policing them would break the
"sent unchanged and unpromised" contract this hatch exists to offer, which
is the same contract the value side's hatch offers. The non-goals below
therefore describe the NORMALIZED path — what `properties` builds.

What this tool does NOT do, on every path including its errors. Read as
statements about the normalized schema; `raw_properties` is unpromised and
is covered above:

* **No views.** Notion's API exposes no view configuration at all, so the
  database arrives with whatever default view Notion gives it, and nothing
  here can change that. This one holds for `raw_properties` too — a view is
  not a property, so there is no raw definition that could configure one.
* **No schema UPDATES.** `properties` creates a schema once. Renaming a
  property, adding one later, or changing a type is not offered by this
  toolkit at all — it is a Notion UI operation as far as these tools are
  concerned. The exception is the caller's own: a raw dual relation writes
  a synced property into the target data source, as above.
* **No additional data sources.** Exactly the one Notion creates with the
  database; multi-source databases are not built here, by either map.
* **No group configuration.** Status OPTIONS are built from `properties`
  (see above), but the three groups they may be assigned to are Notion's
  and cannot be renamed, recoloured or added to from here. A `group`
  outside those three is refused; `raw_properties` is unpromised as ever
  and is not checked against them.
* **No rollback.** If the schema is accepted but is not what you wanted,
  the database exists and this toolkit cannot delete or amend it. True of
  raw definitions as well, which is what makes them worth reading twice.

**Notion canonicalizes what it stores** (#2039): the title and description
are sent as plain rich text, and what Notion echoes back — and what later
reads return — is Notion's normalized form of it, which need not be
byte-identical to the string sent. Nothing here emits annotations or links;
`raw_properties` is the way to send richer JSON.

Concurrency is not a concern for this tool in the way it is for the editing
tools: it creates a new object rather than modifying a shared one, so there
is no other writer's change for it to overwrite. It is, however, **not
idempotent** — two calls make two databases — which is why an ambiguous
failure here is never retried automatically and says so.
Parameter Type Required Description
parent_page_id string Yes The page the database is created inside. Id or Notion URL. The page must be shared with this Notion connection.
title string Yes The database's NAME, as shown in Notion. This is not a column — `properties` must still define exactly one property of type 'title' to hold each row's name.
properties object | null No The column schema: property name -> {'type': ..., ...config}. Exactly one must be {'type': 'title'}. Supported types: title, rich_text, number, checkbox, select, multi_select, status, date, url, email, phone_number, relation, people. `select`/`multi_select` take `options` (names, or {name, color}); `status` takes the same plus an optional `group` per option, which must be one of 'To-do', 'In progress', 'Complete' — Notion has no others and they cannot be created. Omitting `options` on a status is fine: Notion fills in its default template. `number` takes an optional `format`; `relation` takes a `data_source_id`.
raw_properties object | null No Escape hatch: property name -> Notion's own property-schema JSON, e.g. {'Est': {'formula': {'expression': '1+1'}}}. Sent unvalidated and unchanged, so this tool's non-goals — which describe the normalized `properties` path — do not bind it: a raw `status` carrying options creates those options, and a raw DUAL relation also adds a synced property to the TARGET data source, a schema write outside the page named in `parent_page_id`. A name may appear in `properties` or here, never both.
description string | null No Optional prose shown under the database title in Notion.
inline boolean | null No Display the database inline in its parent page rather than as a full-page database. Sent as Notion's `is_inline`; omitted entirely when not given, so Notion's own default (false) applies.
Parameter schema (JSON)
{
  "properties": {
    "parent_page_id": {
      "description": "The page the database is created inside. Id or Notion URL. The page must be shared with this Notion connection.",
      "minLength": 1,
      "title": "Parent Page Id",
      "type": "string"
    },
    "title": {
      "description": "The database's NAME, as shown in Notion. This is not a column — `properties` must still define exactly one property of type 'title' to hold each row's name.",
      "maxLength": 2000,
      "minLength": 1,
      "title": "Title",
      "type": "string"
    },
    "properties": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The column schema: property name -> {'type': ..., ...config}. Exactly one must be {'type': 'title'}. Supported types: title, rich_text, number, checkbox, select, multi_select, status, date, url, email, phone_number, relation, people. `select`/`multi_select` take `options` (names, or {name, color}); `status` takes the same plus an optional `group` per option, which must be one of 'To-do', 'In progress', 'Complete' — Notion has no others and they cannot be created. Omitting `options` on a status is fine: Notion fills in its default template. `number` takes an optional `format`; `relation` takes a `data_source_id`.",
      "title": "Properties"
    },
    "raw_properties": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Escape hatch: property name -> Notion's own property-schema JSON, e.g. {'Est': {'formula': {'expression': '1+1'}}}. Sent unvalidated and unchanged, so this tool's non-goals — which describe the normalized `properties` path — do not bind it: a raw `status` carrying options creates those options, and a raw DUAL relation also adds a synced property to the TARGET data source, a schema write outside the page named in `parent_page_id`. A name may appear in `properties` or here, never both.",
      "title": "Raw Properties"
    },
    "description": {
      "anyOf": [
        {
          "maxLength": 2000,
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Optional prose shown under the database title in Notion.",
      "title": "Description"
    },
    "inline": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Display the database inline in its parent page rather than as a full-page database. Sent as Notion's `is_inline`; omitted entirely when not given, so Notion's own default (false) applies.",
      "title": "Inline"
    }
  },
  "required": [
    "parent_page_id",
    "title"
  ],
  "title": "CreateDatabaseParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "properties": {
    "database_id": {
      "description": "The database container. Use it with inspect_database.",
      "title": "Database Id",
      "type": "string"
    },
    "data_source_id": {
      "description": "The data source rows live in. THIS is what create_page's parent_data_source_id and query_data_source take — not database_id.",
      "title": "Data Source Id",
      "type": "string"
    },
    "url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The database's Notion URL, when the response carried one. Absent is not a failure: unlike the two ids, this is not required for the result to be usable, so it is not guarded.",
      "title": "Url"
    },
    "properties_defined": {
      "description": "Every property name the new schema defines, sorted.",
      "items": {
        "type": "string"
      },
      "title": "Properties Defined",
      "type": "array"
    }
  },
  "required": [
    "database_id",
    "data_source_id"
  ],
  "title": "CreateDatabaseResult",
  "type": "object"
}

# create_page write

Create a Notion page under a page, database or data source, with coerced properties and an optional Markdown body.

Full description
Create a Notion page.

`POST /v1/pages` [verified — developers.notion.com/reference/post-page].
The body is sent as Markdown in the request's `markdown` field, which that
page documents as "Page content as Notion-flavored Markdown".

**The parent decides which properties exist.** A data-source (or database)
parent means the page is a row, and its properties are that data source's
schema — fetched first, then validated against before any write is sent. A
rejection therefore means no page was created, even though the fetch itself
was a real request to Notion. A page parent means the page is a plain
sub-page, and [verified, same page] "If the new page is a child of an
existing page, `title` is the only valid property in the `properties` body
parameter"; anything else is refused here with that reason rather than sent
and 400'd.

**Nothing is written on a guessed schema.** If the parent cannot be
resolved, the schema cannot be read, or a database has several data sources
and none was named, this fails *before* the create — so the failure means no
page was created, not that one might have been.

Property values are coerced from a compact form; see the `properties` field
description, and `inspect_database` for the schema and its option names. Any
value this toolkit cannot build is refused with an explanation, and
`raw_properties` takes Notion's own JSON for those cases.

Where the schema came from this toolkit's cache and Notion refuses the
create with a 400, the cached entry is dropped at that point — not left to
expire — and the create is rebuilt once against a freshly read schema. The
second send is safe because a 400 means Notion rejected the request rather
than creating the page (**[inference, not a provider statement]**; see
`_send_recovering_from_a_stale_schema`), so there is no page to duplicate.

The result is compact — page id, URL, and what was set. The created page is
not echoed back; read it with `get_page` if you need it. Notion
canonicalizes Markdown as it stores it, so what `get_page` returns is not
always the Markdown sent here — a pipe table comes back as
`<table header-row="true">` HTML — and `update_page_content`'s `find`
matches that stored form rather than what was sent.

Notion offers an asynchronous mode for large Markdown page creation. This
toolkit does not use it: it would return a task id instead of a page, and a
tool that reports a write it has not seen finish cannot honestly say whether
it happened. Content too large for one request is refused with guidance to
build the page up with `append_content` instead.
Parameter Type Required Description
parent_page_id string | null No Create the page as a child of this page. Id or Notion URL. The new page then has only a title — Notion allows no other property on a page outside a database.
parent_data_source_id string | null No Create the page as a row of this data source. Preferred over parent_database_id: it skips the deprecated database lookup and is required when a database has more than one data source.
parent_database_id string | null No Create the page as a row of this database's single data source. Id or Notion URL. Fails, listing them, if the database has more than one data source.
title string | null No The page title. Convenience for setting the title property without having to know its name, which differs per database ('Name', 'Task', ...). Giving it here AND in `properties` is an error rather than one silently winning.
properties object | null No Property values keyed by property NAME, in this toolkit's normalized form: a string for title/rich_text/select/status/url/email/phone_number, a number for number, true/false for checkbox, a list of names for multi_select, a list of ids for relation and people, and an ISO 8601 string or {start, end, time_zone} for date. A datetime MUST carry an explicit UTC offset. Validated against the data source's schema, which is READ first, before any write is sent — so a rejection here means no page was created — and that schema may be a few minutes old, so a select/status option deleted or renamed in Notion very recently can still pass validation and be created anew by Notion on write.
raw_properties object | null No Escape hatch: Notion's own property JSON, keyed by property name, sent UNVALIDATED and unchanged. Use it for property types this toolkit does not build (files) and for values a plain string cannot express (annotated rich text). A name may not appear in both `properties` and here.
markdown string | null No The page body as Notion-flavored Markdown. Omit for a page with properties but no content.
Parameter schema (JSON)
{
  "properties": {
    "parent_page_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Create the page as a child of this page. Id or Notion URL. The new page then has only a title — Notion allows no other property on a page outside a database.",
      "title": "Parent Page Id"
    },
    "parent_data_source_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Create the page as a row of this data source. Preferred over parent_database_id: it skips the deprecated database lookup and is required when a database has more than one data source.",
      "title": "Parent Data Source Id"
    },
    "parent_database_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Create the page as a row of this database's single data source. Id or Notion URL. Fails, listing them, if the database has more than one data source.",
      "title": "Parent Database Id"
    },
    "title": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The page title. Convenience for setting the title property without having to know its name, which differs per database ('Name', 'Task', ...). Giving it here AND in `properties` is an error rather than one silently winning.",
      "title": "Title"
    },
    "properties": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Property values keyed by property NAME, in this toolkit's normalized form: a string for title/rich_text/select/status/url/email/phone_number, a number for number, true/false for checkbox, a list of names for multi_select, a list of ids for relation and people, and an ISO 8601 string or {start, end, time_zone} for date. A datetime MUST carry an explicit UTC offset. Validated against the data source's schema, which is READ first, before any write is sent — so a rejection here means no page was created — and that schema may be a few minutes old, so a select/status option deleted or renamed in Notion very recently can still pass validation and be created anew by Notion on write.",
      "title": "Properties"
    },
    "raw_properties": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Escape hatch: Notion's own property JSON, keyed by property name, sent UNVALIDATED and unchanged. Use it for property types this toolkit does not build (files) and for values a plain string cannot express (annotated rich text). A name may not appear in both `properties` and here.",
      "title": "Raw Properties"
    },
    "markdown": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The page body as Notion-flavored Markdown. Omit for a page with properties but no content.",
      "title": "Markdown"
    }
  },
  "title": "CreatePageParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Url"
    },
    "created_time": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Created Time"
    },
    "properties_written": {
      "description": "Every property name this call SENT, sorted. Taken from the request this tool built, not from Notion's response — so it says what was asked for on a call that succeeded, not what Notion echoed back. Read the page to see stored values.",
      "items": {
        "type": "string"
      },
      "title": "Properties Written",
      "type": "array"
    },
    "content_written": {
      "description": "Whether a Markdown body was sent with the page.",
      "title": "Content Written",
      "type": "boolean"
    }
  },
  "required": [
    "page_id",
    "content_written"
  ],
  "title": "CreatePageResult",
  "type": "object"
}

# get_comments read

Read the comments on a Notion page. One page of results per call; requires the connection's read-comment capability.

Full description
Read comments on a Notion page.

`GET /v1/comments?block_id={page_id}` [verified —
developers.notion.com/reference/list-comments, whose `block_id` parameter is
documented as "Identifier for a Notion block or page"]. Each comment carries
the `discussion_id` of its thread [verified].

**Reading comments is a separate Notion capability from reading content.**
[verified, same page: "Attempting to call this endpoint without read comment
capabilities will return an HTTP response with a 403 status code."] If this
tool returns a 403 while other tools work, the capability is what is
missing, and it is enabled on the integration in Notion, then reconnected in
the Toolforest portal.

**One request per call** — this tool does not loop. `has_more` and
`next_cursor` are Notion's own; pass `next_cursor` back as `cursor` to
continue. There is no `complete` field precisely because a single page of
results makes no completeness claim.

Comment fields beyond `id` and `discussion_id` are **[unverified — live
contract test]** in their exact names, so each comment also carries Notion's
own object under `raw`.
Parameter Type Required Description
page_id string Yes Page id or Notion URL. Notion's comments endpoint accepts a page id in its block_id parameter.
cursor string | null No Continuation cursor from a previous call's next_cursor.
page_size integer No Comments per request. Notion's maximum is 100. Default: 50
Parameter schema (JSON)
{
  "properties": {
    "page_id": {
      "description": "Page id or Notion URL. Notion's comments endpoint accepts a page id in its block_id parameter.",
      "title": "Page Id",
      "type": "string"
    },
    "cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Continuation cursor from a previous call's next_cursor.",
      "title": "Cursor"
    },
    "page_size": {
      "default": 50,
      "description": "Comments per request. Notion's maximum is 100.",
      "maximum": 100,
      "minimum": 1,
      "title": "Page Size",
      "type": "integer"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "GetCommentsParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "$defs": {
    "Comment": {
      "properties": {
        "id": {
          "title": "Id",
          "type": "string"
        },
        "discussion_id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "The thread this comment belongs to.",
          "title": "Discussion Id"
        },
        "created_time": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Created Time"
        },
        "author": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion user id of the author, when Notion returns one.",
          "title": "Author"
        },
        "text": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Plain text of the comment. Inline formatting and mention targets are not preserved here — read `raw` for those.",
          "title": "Text"
        },
        "raw": {
          "anyOf": [
            {
              "additionalProperties": true,
              "type": "object"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's own comment object, verbatim.",
          "title": "Raw"
        }
      },
      "required": [
        "id"
      ],
      "title": "Comment",
      "type": "object"
    }
  },
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "comments": {
      "items": {
        "$ref": "#/$defs/Comment"
      },
      "title": "Comments",
      "type": "array"
    },
    "has_more": {
      "description": "Notion's own `has_more` for this response, verbatim.",
      "title": "Has More",
      "type": "boolean"
    },
    "next_cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Pass back as `cursor` to fetch the next page.",
      "title": "Next Cursor"
    }
  },
  "required": [
    "page_id",
    "comments",
    "has_more"
  ],
  "title": "GetCommentsResult",
  "type": "object"
}

# get_page read

Read a Notion page: its properties plus its body as Markdown, with Notion's truncation flags passed through verbatim.

Full description
Read one Notion page — properties, and optionally its body as Markdown.

Two bounded requests: `GET /v1/pages/{id}` for properties, and
`GET /v1/pages/{id}/markdown` for the body [verified —
developers.notion.com/reference/retrieve-page-markdown, which returns
`{object: "page_markdown", id, markdown, truncated, unknown_block_ids}`].

**Truncation is reported, never hidden**, in three places:

* `truncated` — the page exceeded Notion's record limit (approximately
  20,000 blocks) [verified — Notion's documentation] and the Markdown you
  get is partial. Notion's field, passed through verbatim. **Never
  observed true**; reaching it needs a ~20,000-block page, and the
  dogfood run stopped at about a tenth of that with its last append
  already at 94.7% of the 16 s HTTP attempt allowance (#2032), so no such
  page has been built.
* `unknown_block_ids` — Notion's field, passed through verbatim, and
  **never observed non-empty** (#2046). An earlier version of this
  docstring said unsupported block types land here. **They do not.** What
  populates it, if anything, is unknown.
* `inline_unknown_blocks` — where unsupported blocks ACTUALLY surface, and
  this toolkit's own parse rather than a field Notion returns. Notion
  renders a block it will not render inline AS `<unknown url="…#<block
  id>" alt="bookmark"/>`, in place, inside the Markdown [observed on dev
  and test]. Without this parse those ids are invisible.

**`expand_unknown_blocks` is very likely dead, and is not wired to
`inline_unknown_blocks` on purpose.** It re-fetches each id in
`unknown_block_ids` through the markdown endpoint, which Notion's docs
describe as accepting a block id in the page-id position — but that
`[verified]` was read off the documentation, never exercised, and the
sibling endpoint `GET /v1/pages/{id}` rejects a block id outright with
"Provided ID … is a block, not a page. Use the retrieve block API instead"
[observed, 2026-03-11]. So the loop is doubly unreachable: its input has
never been non-empty, and its request may not be accepted even if it were.
Pointing it at `inline_unknown_blocks` would convert an inert path into
one that issues a request per unknown block on the strength of a
documentation claim the neighbouring endpoint contradicts. That wants its
own verification, not a quiet re-target. Tracked on #2046.

A failure on one id is reported against that id and does not fail the
call. The loop stops at `max_unknown_expansions` or when this invocation
runs short of runtime, and says which — `expansions_complete` is true only
when every id was attempted, **null when no expansion was attempted at
all**, and vacuously true when there was nothing to attempt.

Fields that describe the page body — `markdown`, `truncated`,
`unknown_block_ids`, `inline_unknown_blocks`, `expansions_complete` — are
all **null** when `include_content=false`, because nothing was read and a
`false` there would be a claim about a fetch that never happened.

Partial-success rule: if the properties read succeeds and the content read
then fails, **the whole call fails**. It does not return the properties with
a null `markdown`, because that result would be byte-identical to a
successful `include_content=false` call — the caller could not tell a page
with no readable body from one they did not ask for. A failed expansion is
different and IS partial, because it is reported against the specific block
id that failed.

Property values may themselves be truncated by Notion; see the `properties`
field description and use `get_page_property` for a complete value.
Parameter Type Required Description
page_id string Yes Page id or any Notion URL ending in one. Dashed and undashed UUIDs are both accepted. A Notion URL yields a page or database id — never a block id.
include_content boolean No Fetch the page body as Markdown as well as its properties. Default: true
expand_unknown_blocks boolean No Re-fetch the ids in `unknown_block_ids`, one request each, bounded by max_unknown_expansions and by this invocation's remaining runtime. **In practice this does nothing**: that field has never been observed non-empty (#2046). It does NOT expand `inline_unknown_blocks`, which is where unsupported blocks actually surface — see the tool description for why that is not a one-line change. Default: false
max_unknown_expansions integer No Hard cap on expansion requests. Default: 10
Parameter schema (JSON)
{
  "properties": {
    "page_id": {
      "description": "Page id or any Notion URL ending in one. Dashed and undashed UUIDs are both accepted. A Notion URL yields a page or database id — never a block id.",
      "title": "Page Id",
      "type": "string"
    },
    "include_content": {
      "default": true,
      "description": "Fetch the page body as Markdown as well as its properties.",
      "title": "Include Content",
      "type": "boolean"
    },
    "expand_unknown_blocks": {
      "default": false,
      "description": "Re-fetch the ids in `unknown_block_ids`, one request each, bounded by max_unknown_expansions and by this invocation's remaining runtime. **In practice this does nothing**: that field has never been observed non-empty (#2046). It does NOT expand `inline_unknown_blocks`, which is where unsupported blocks actually surface — see the tool description for why that is not a one-line change.",
      "title": "Expand Unknown Blocks",
      "type": "boolean"
    },
    "max_unknown_expansions": {
      "default": 10,
      "description": "Hard cap on expansion requests.",
      "maximum": 25,
      "minimum": 1,
      "title": "Max Unknown Expansions",
      "type": "integer"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "GetPageParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "$defs": {
    "BlockExpansion": {
      "properties": {
        "block_id": {
          "title": "Block Id",
          "type": "string"
        },
        "markdown": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Markdown"
        },
        "truncated": {
          "anyOf": [
            {
              "type": "boolean"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's `truncated` for this subtree, verbatim.",
          "title": "Truncated"
        },
        "error": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Why this one block could not be expanded. A failure here is per-block: the rest of the page is still returned.",
          "title": "Error"
        }
      },
      "required": [
        "block_id"
      ],
      "title": "BlockExpansion",
      "type": "object"
    },
    "CompactProperty": {
      "description": "One page property, compacted — and honest about what compaction drops.",
      "properties": {
        "id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Stable property id. Pass this to get_page_property.",
          "title": "Id"
        },
        "type": {
          "description": "Notion's property type.",
          "title": "Type",
          "type": "string"
        },
        "value": {
          "default": null,
          "description": "A compact rendering of the property. Rich text and titles become their plain text, so inline formatting, links and mention targets are NOT preserved here — read `raw` for those.",
          "title": "Value"
        },
        "truncated": {
          "default": false,
          "description": "True when Notion itself reported this property is incomplete in a page read. Notion signals this with `has_more` on relation properties [verified]. It does NOT signal it for the 25-mention cap on title and rich_text properties, so `false` here is not a promise that a long rich_text is whole. Use get_page_property for the complete value of any property.",
          "title": "Truncated",
          "type": "boolean"
        },
        "raw": {
          "anyOf": [
            {},
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's own value for this property, verbatim. Present whenever the type is one this toolkit does not compact, and whenever the compact `value` is lossy.",
          "title": "Raw"
        }
      },
      "required": [
        "type"
      ],
      "title": "CompactProperty",
      "type": "object"
    },
    "InlineUnknownBlock": {
      "properties": {
        "block_id": {
          "description": "The block id, read from the fragment of the tag's `url`. This toolkit's own parse of Notion's Markdown, NOT a field Notion returned — see the `inline_unknown_blocks` description.",
          "title": "Block Id",
          "type": "string"
        },
        "kind": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's `alt` on the tag, verbatim — 'bookmark' on the one case observed. Null when the tag carried no `alt`.",
          "title": "Kind"
        }
      },
      "required": [
        "block_id"
      ],
      "title": "InlineUnknownBlock",
      "type": "object"
    }
  },
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Url"
    },
    "created_time": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Created Time"
    },
    "last_edited_time": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Last Edited Time"
    },
    "archived": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Archived"
    },
    "parent": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Parent"
    },
    "properties": {
      "type": "object",
      "additionalProperties": {
        "$ref": "#/$defs/CompactProperty"
      },
      "description": "Compacted page properties. Notion truncates some property values in a page read — relation properties past 25 related pages (flagged per property as `truncated`), and title/rich_text past 25 inline mentions (which Notion does NOT flag). Use get_page_property with a property's `id` for its complete value.",
      "title": "Properties"
    },
    "markdown": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The page body as Markdown, or null when include_content=false.",
      "title": "Markdown"
    },
    "truncated": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own `truncated`, verbatim: the page exceeded Notion's record limit (approximately 20,000 blocks) and the Markdown is partial. **Null when include_content=false** — no content was read, so there is nothing to report about it; a false here would be a claim about a fetch that never happened.",
      "title": "Truncated"
    },
    "unknown_block_ids": {
      "anyOf": [
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own `unknown_block_ids`, verbatim. **Has never been observed non-empty** (#2046): unsupported block types do NOT land here — they are rendered inline as `<unknown …/>` and surface in `inline_unknown_blocks` instead. What, if anything, populates this field is unknown; the permission-denied path is the remaining candidate and is untested. Kept and passed through because it is Notion's field to populate, not because anything here has seen it do so. **Null when include_content=false**, for the same reason as `truncated` — an empty list would wrongly say Notion rendered everything.",
      "title": "Unknown Block Ids"
    },
    "inline_unknown_blocks": {
      "anyOf": [
        {
          "items": {
            "$ref": "#/$defs/InlineUnknownBlock"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Blocks Notion declined to render, read out of the Markdown by THIS TOOLKIT rather than reported by Notion as a field. Notion emits `<unknown url=\"…#<block id>\" alt=\"bookmark\"/>` inline where such a block sits, so without this parse the ids are invisible to a caller who is not regexing Markdown themselves. Distinct from `unknown_block_ids` precisely because the provenance differs, and the two are not merged for that reason. **Null when include_content=false.** Note that `expand_unknown_blocks` does NOT expand these — see that parameter.",
      "title": "Inline Unknown Blocks"
    },
    "expansions": {
      "description": "One entry per attempted expansion, successful or not.",
      "items": {
        "$ref": "#/$defs/BlockExpansion"
      },
      "title": "Expansions",
      "type": "array"
    },
    "expansions_complete": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "**Null when no expansion was attempted** — either expand_unknown_blocks was false or the page content was not read. No loop ran, so there is no completeness to claim. True when every id in unknown_block_ids was attempted; false when the cap or the runtime deadline stopped the loop first. **True is also what you get when `unknown_block_ids` was empty** — vacuously, since every id there was WAS attempted — so read it together with `expansions`, which is empty in that case. Since that field has never been observed non-empty (#2046), the vacuous true is in practice the only true anyone has seen.",
      "title": "Expansions Complete"
    },
    "expansions_incomplete_reason": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "`local_request_cap` (max_unknown_expansions reached) or `deadline` (this invocation ran short of runtime). Null when complete, and null when no expansion was attempted.",
      "title": "Expansions Incomplete Reason"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "GetPageResult",
  "type": "object"
}

# get_page_property read

Read one Notion page property in full, past the 25-reference limit that page reads truncate at.

Full description
Read a single page property completely, past the 25-reference truncation.

`GET /v1/pages/{page_id}/properties/{property_id}` [verified —
developers.notion.com/reference/retrieve-a-page-property]. This is the
endpoint Notion names for exactly this job: "In cases where a property item
has more than 25 references, this endpoint should be used, rather than
Retrieve a page." A page read caps a relation property at 25 related pages
(flagged with `has_more`) and title/rich_text at 25 inline mentions
[verified — developers.notion.com/reference/retrieve-a-page].

`property_id` is the stable id, not the display name; `inspect_database`
returns it, and so does every compacted property in a page read. It is sent
exactly as given — Notion property ids often contain percent-escapes, and
those are preserved rather than re-encoded. Whether Notion expects the
escaped or the unescaped form for every property type is **[unverified —
live contract test]**; passing the id through unchanged is what makes the
value `inspect_database` hands you the value that works.

Notion paginates this endpoint for some property types and not others. When
it does not, `paginated` is false and `values` holds the single item it
returned. **[unverified — live contract test]** the exact keys of the
non-paginated response shape; the paginated shape (`results`, `has_more`,
`next_cursor`) is verified.

Bounded exactly like `query_data_source`: a `max_requests` cap, a deadline
check before every request, and a cursor-repeat guard, with `complete: true`
emitted only on Notion-reported exhaustion. `provider_result_limit` cannot
occur here — Notion documents `request_status` only for search and
data-source query, so this tool never claims a provider cap it has no signal
for.
Parameter Type Required Description
page_id string Yes Page id or Notion URL.
property_id string Yes Stable property id, as returned by inspect_database or in a page read's compacted properties. This is the property ID, not its display name.
cursor string | null No Continuation cursor from a previous call's next_cursor.
page_size integer No Items per request. Notion's maximum is 100. Default: 50
max_requests integer No Hard cap on requests this call may make. Default: 3
Parameter schema (JSON)
{
  "properties": {
    "page_id": {
      "description": "Page id or Notion URL.",
      "title": "Page Id",
      "type": "string"
    },
    "property_id": {
      "description": "Stable property id, as returned by inspect_database or in a page read's compacted properties. This is the property ID, not its display name.",
      "title": "Property Id",
      "type": "string"
    },
    "cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Continuation cursor from a previous call's next_cursor.",
      "title": "Cursor"
    },
    "page_size": {
      "default": 50,
      "description": "Items per request. Notion's maximum is 100.",
      "maximum": 100,
      "minimum": 1,
      "title": "Page Size",
      "type": "integer"
    },
    "max_requests": {
      "default": 3,
      "description": "Hard cap on requests this call may make.",
      "maximum": 5,
      "minimum": 1,
      "title": "Max Requests",
      "type": "integer"
    }
  },
  "required": [
    "page_id",
    "property_id"
  ],
  "title": "GetPagePropertyParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "property_id": {
      "title": "Property Id",
      "type": "string"
    },
    "type": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's property type for this item, verbatim.",
      "title": "Type"
    },
    "values": {
      "description": "The property items Notion returned, verbatim. For a property type Notion does not paginate, this holds exactly one item.",
      "items": {},
      "title": "Values",
      "type": "array"
    },
    "paginated": {
      "description": "Whether Notion returned a paginated list for this property type. False means the whole value came back in one object and there was nothing to page through.",
      "title": "Paginated",
      "type": "boolean"
    },
    "has_more": {
      "description": "Notion's own `has_more` from the last response, verbatim.",
      "title": "Has More",
      "type": "boolean"
    },
    "next_cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Set only when continuing with it can make progress.",
      "title": "Next Cursor"
    },
    "complete": {
      "description": "True when nothing further remains to fetch. A result cut short by max_requests or by the runtime deadline is always false. Note the one case where this is not a Notion report but a property of the shape: for a property type Notion does NOT paginate, the whole value arrives in a single non-list object, so there is nothing to page through and this is true without Notion having said anything about exhaustion. `paginated` tells the two cases apart.",
      "title": "Complete",
      "type": "boolean"
    },
    "incomplete_reason": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "One of: local_request_cap (max_requests reached), deadline (this invocation ran short of runtime), cursor_repeated (Notion returned a cursor it had already given). Null when complete. Note provider_result_limit does not occur here: Notion documents request_status only for search and data-source query.",
      "title": "Incomplete Reason"
    },
    "continuation_usable": {
      "title": "Continuation Usable",
      "type": "boolean"
    },
    "requests_made": {
      "title": "Requests Made",
      "type": "integer"
    }
  },
  "required": [
    "page_id",
    "property_id",
    "paginated",
    "has_more",
    "complete",
    "continuation_usable",
    "requests_made"
  ],
  "title": "GetPagePropertyResult",
  "type": "object"
}

# inspect_database read

Inspect a Notion data source's full property schema with stable property ids; also lists sibling data sources when given a database id.

Full description
Read a Notion database's data sources and one data source's schema.

Under API version 2026-03-11 a database is a container: `GET
/v1/databases/{id}` returns `data_sources: [{id, name}]` and **no property
schema** [verified — developers.notion.com/reference/retrieve-a-database],
and the schema comes from `GET /v1/data_sources/{data_source_id}`
[verified — developers.notion.com/reference/retrieve-a-data-source].

That database endpoint is **deprecated as of API version 2025-09-03**
[verified, same page]. Passing `data_source_id` directly skips it entirely
and is the preferred call. Whether the deprecated endpoint still functions
under 2026-03-11 is **[unverified — live contract test]**.

When a database has several data sources and none was named, this fails with
an error listing them rather than picking one.

`read_only` on each property is this toolkit's own classification by type,
not a provider field — the description on that field says so.
Parameter Type Required Description
database_id string | null No Database id or Notion URL. Omit if you already have a data_source_id.
data_source_id string | null No Data source id. Preferred: it skips the deprecated database lookup, and it is required when a database has more than one.
Parameter schema (JSON)
{
  "properties": {
    "database_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Database id or Notion URL. Omit if you already have a data_source_id.",
      "title": "Database Id"
    },
    "data_source_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Data source id. Preferred: it skips the deprecated database lookup, and it is required when a database has more than one.",
      "title": "Data Source Id"
    }
  },
  "title": "InspectDatabaseParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "$defs": {
    "SchemaProperty": {
      "properties": {
        "name": {
          "title": "Name",
          "type": "string"
        },
        "id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Stable property id. Pass this to get_page_property.",
          "title": "Id"
        },
        "type": {
          "title": "Type",
          "type": "string"
        },
        "options": {
          "anyOf": [
            {
              "items": {
                "type": "string"
              },
              "type": "array"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Option names for select, multi_select and status properties.",
          "title": "Options"
        },
        "relation_target": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "The data source or database a relation points at, when Notion names one.",
          "title": "Relation Target"
        },
        "read_only": {
          "description": "Derived by this toolkit from the property TYPE (formula, rollup, created/last_edited stamps, unique_id). Notion's schema exposes no read-only marker, so this is a classification, not a provider field.",
          "title": "Read Only",
          "type": "boolean"
        },
        "raw": {
          "anyOf": [
            {
              "additionalProperties": true,
              "type": "object"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's own property configuration, verbatim.",
          "title": "Raw"
        }
      },
      "required": [
        "name",
        "type",
        "read_only"
      ],
      "title": "SchemaProperty",
      "type": "object"
    }
  },
  "properties": {
    "database_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Database Id"
    },
    "data_source_id": {
      "title": "Data Source Id",
      "type": "string"
    },
    "data_sources": {
      "description": "Every data source of the database, as `{id, name}` — but populated ONLY when this call resolved through database_id. Passing data_source_id skips the database lookup entirely (it is the preferred call), so this is empty then, and empty does not mean the database has no other data sources.",
      "items": {
        "additionalProperties": true,
        "type": "object"
      },
      "title": "Data Sources",
      "type": "array"
    },
    "title": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Title"
    },
    "properties": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/SchemaProperty"
      },
      "title": "Properties"
    }
  },
  "required": [
    "data_source_id"
  ],
  "title": "InspectDatabaseResult",
  "type": "object"
}

# query_data_source read

Query rows of a Notion data source with native filters and sorts. Bounded pagination with an honest completeness contract.

Full description
Query the rows of a Notion data source.

`POST /v1/data_sources/{data_source_id}/query` [verified —
developers.notion.com/reference/query-a-data-source]. `filter` and `sorts`
are Notion's own objects and are passed through verbatim; this toolkit does
not validate or translate them.

**Completeness is reported honestly, and `complete: true` means one thing
only: Notion said there was no more.** The loop is bounded three ways — a
`max_requests` cap, a runtime-deadline check before every request, and a
cursor-repeat guard — and each stop is reported distinctly:

==============================  ==========  ==========================  ==================
Why it stopped                  `complete`  `incomplete_reason`         `next_cursor`
==============================  ==========  ==========================  ==================
Notion had no more              true        null                        null
Notion capped the query         false       `provider_result_limit`     null (unusable)
`max_requests` reached          false       `local_request_cap`         usable
ran short of runtime            false       `deadline`                  usable
Notion repeated a cursor        false       `cursor_repeated`           null (unusable)
==============================  ==========  ==========================  ==================

The second row is the one that bites: Notion paginates through at most
10,000 results per query and, past that, answers `has_more: false` together
with `request_status: {"type": "incomplete", "incomplete_reason":
"query_result_limit_reached"}` [verified]. Reading `has_more` alone would
make a capped query look exhausted. When that happens there is no cursor to
continue with — Notion has refused, not paused — so the only way past the
cap is to narrow the query and ask again.

`suggested_partition` helps with that, but **only under one ordering**, and
it is omitted rather than guessed otherwise. The boundary it carries is the
last returned row's `created_time`, which is a frontier only if the rows
came back ascending by `created_time`. Under caller-supplied `sorts` on
anything else — or under Notion's unspecified default ordering, which this
toolkit does not assume — returned rows may postdate the boundary (a
follow-up re-fetches them) and unreturned rows may predate it (a follow-up
SKIPS them, which is the silent data loss the whole stop-reason contract
exists to prevent). So the field is populated only when `sorts` began with
`{"timestamp": "created_time", "direction": "ascending"}`. To partition a
capped query, pass that sort and run it again.

**The boundary it gives you is inclusive (`on_or_after`), so the follow-up
query will repeat rows: de-duplicate by `page_id`.** That overlap is
deliberate. Ascending order orders the rows, but the 10,000-result cap is a
count, not a timestamp — it can fall part-way through a run of rows that
share the last returned `created_time`, and `created_time` has no documented
uniqueness guarantee (bulk imports and scripted writes produce ties
routinely). An exclusive `after` would then omit the rest of that run
permanently and report nothing, which is the same silent loss under a
different name. Re-reading a bounded tail is the price of not losing rows,
and the hint states it in `overlaps` and `deduplicate_by` rather than
leaving the caller to discover it.

Row properties are compacted and can themselves be truncated by Notion; see
`get_page_property` for a complete property value.
Parameter Type Required Description
data_source_id string | null No Data source id. Preferred over database_id.
database_id string | null No Database id or Notion URL. Resolved to its single data source; fails with the list when the database has more than one.
filter object | null No A native Notion filter object, passed through verbatim. This toolkit does not validate or translate it — see Notion's query-a-data-source reference for the shape.
sorts array<object> | null No A native Notion sorts array, passed through verbatim. One value has an extra effect here: leading with `{"timestamp": "created_time", "direction": "ascending"}` is what makes `suggested_partition` available if Notion caps the query, because only that ordering makes the last row a real boundary.
cursor string | null No Continuation cursor from a previous call's next_cursor.
page_size integer No Rows per request. Notion's maximum is 100. Default: 50
max_requests integer No Hard cap on requests this call may make. Reaching it returns complete=false with incomplete_reason=local_request_cap and a usable next_cursor. Default: 3
Parameter schema (JSON)
{
  "properties": {
    "data_source_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Data source id. Preferred over database_id.",
      "title": "Data Source Id"
    },
    "database_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Database id or Notion URL. Resolved to its single data source; fails with the list when the database has more than one.",
      "title": "Database Id"
    },
    "filter": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "A native Notion filter object, passed through verbatim. This toolkit does not validate or translate it — see Notion's query-a-data-source reference for the shape.",
      "title": "Filter"
    },
    "sorts": {
      "anyOf": [
        {
          "items": {
            "additionalProperties": true,
            "type": "object"
          },
          "type": "array"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "A native Notion sorts array, passed through verbatim. One value has an extra effect here: leading with `{\"timestamp\": \"created_time\", \"direction\": \"ascending\"}` is what makes `suggested_partition` available if Notion caps the query, because only that ordering makes the last row a real boundary.",
      "title": "Sorts"
    },
    "cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Continuation cursor from a previous call's next_cursor.",
      "title": "Cursor"
    },
    "page_size": {
      "default": 50,
      "description": "Rows per request. Notion's maximum is 100.",
      "maximum": 100,
      "minimum": 1,
      "title": "Page Size",
      "type": "integer"
    },
    "max_requests": {
      "default": 3,
      "description": "Hard cap on requests this call may make. Reaching it returns complete=false with incomplete_reason=local_request_cap and a usable next_cursor.",
      "maximum": 5,
      "minimum": 1,
      "title": "Max Requests",
      "type": "integer"
    }
  },
  "title": "QueryDataSourceParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "$defs": {
    "CompactProperty": {
      "description": "One page property, compacted — and honest about what compaction drops.",
      "properties": {
        "id": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Stable property id. Pass this to get_page_property.",
          "title": "Id"
        },
        "type": {
          "description": "Notion's property type.",
          "title": "Type",
          "type": "string"
        },
        "value": {
          "default": null,
          "description": "A compact rendering of the property. Rich text and titles become their plain text, so inline formatting, links and mention targets are NOT preserved here — read `raw` for those.",
          "title": "Value"
        },
        "truncated": {
          "default": false,
          "description": "True when Notion itself reported this property is incomplete in a page read. Notion signals this with `has_more` on relation properties [verified]. It does NOT signal it for the 25-mention cap on title and rich_text properties, so `false` here is not a promise that a long rich_text is whole. Use get_page_property for the complete value of any property.",
          "title": "Truncated",
          "type": "boolean"
        },
        "raw": {
          "anyOf": [
            {},
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's own value for this property, verbatim. Present whenever the type is one this toolkit does not compact, and whenever the compact `value` is lossy.",
          "title": "Raw"
        }
      },
      "required": [
        "type"
      ],
      "title": "CompactProperty",
      "type": "object"
    },
    "QueryRow": {
      "properties": {
        "page_id": {
          "title": "Page Id",
          "type": "string"
        },
        "url": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Url"
        },
        "created_time": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Created Time"
        },
        "last_edited_time": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Last Edited Time"
        },
        "properties": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/$defs/CompactProperty"
          },
          "title": "Properties"
        }
      },
      "required": [
        "page_id"
      ],
      "title": "QueryRow",
      "type": "object"
    }
  },
  "properties": {
    "data_source_id": {
      "title": "Data Source Id",
      "type": "string"
    },
    "rows": {
      "items": {
        "$ref": "#/$defs/QueryRow"
      },
      "title": "Rows",
      "type": "array"
    },
    "has_more": {
      "description": "Notion's own `has_more` from the last response, verbatim. Note that a query Notion has capped reports has_more=false while complete is also false — the two fields answer different questions.",
      "title": "Has More",
      "type": "boolean"
    },
    "next_cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Set only when continuing with it can actually make progress. Null when Notion capped the query or repeated a cursor, because no cursor helps in either case. Also null on the `deadline` row when the deadline was hit before the FIRST request and no cursor was passed in — there, continuation_usable is true but the right continuation is simply to call again with no cursor.",
      "title": "Next Cursor"
    },
    "complete": {
      "description": "True ONLY when Notion reported it had no further results. A result cut short by this call's max_requests cap or by the runtime deadline is always false.",
      "title": "Complete",
      "type": "boolean"
    },
    "incomplete_reason": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "One of: provider_result_limit (Notion capped the query at its 10,000-result limit), local_request_cap (max_requests reached), deadline (this invocation ran short of runtime), cursor_repeated (Notion returned a cursor it had already given). Null when complete.",
      "title": "Incomplete Reason"
    },
    "continuation_usable": {
      "description": "Whether next_cursor can be used to continue. False on the two stop reasons where no cursor helps.",
      "title": "Continuation Usable",
      "type": "boolean"
    },
    "requests_made": {
      "title": "Requests Made",
      "type": "integer"
    },
    "request_status": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own `request_status` from the last response, verbatim.",
      "title": "Request Status"
    },
    "suggested_partition": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "A `created_time` boundary taken from the LAST row returned, so a follow-up call can narrow the query with a filter instead of a cursor. It is a suggestion built from the data, not a Notion feature. Shaped `{property, on_or_after, overlaps, deduplicate_by}`.\n\n**The boundary is INCLUSIVE and the follow-up WILL repeat rows — de-duplicate by `page_id`.** `on_or_after`, not `after`: the 10,000-result cap falls at a row count, so it can split a group of rows sharing the last returned `created_time`, and an exclusive boundary would silently drop the unreturned members of that group. Overlapping and de-duplicating loses nothing; excluding loses rows you would never know were missing.\n\nPresent only when BOTH hold: Notion capped the query, and this call's sorts made the last row an actual frontier — that is, `sorts` began with `{\"timestamp\": \"created_time\", \"direction\": \"ascending\"}`. Under any other ordering the last row's created_time is not a boundary at all: rows already returned may postdate it and rows never returned may predate it, and no choice of inclusive or exclusive rescues that, so the field is omitted rather than offered as a partition that loses data. To get one, re-run the query with that sort.",
      "title": "Suggested Partition"
    }
  },
  "required": [
    "data_source_id",
    "rows",
    "has_more",
    "complete",
    "continuation_usable",
    "requests_made"
  ],
  "title": "QueryDataSourceResult",
  "type": "object"
}

Search Notion page and database TITLES (not page contents). Not exhaustive and lags recent changes.

Full description
Find Notion pages and data sources whose TITLE matches a query.

What this searches, stated plainly because getting it wrong wastes a whole
workflow:

* **Titles only.** [verified — developers.notion.com/reference/post-search:
  "Returns all pages or data_sources, excluding duplicated linked databases,
  that have titles that include the query param."] Page bodies are not
  searched. There is no tool in this toolkit that searches page bodies.
* **Not exhaustive.** [verified —
  developers.notion.com/reference/search-optimizations-and-limitations:
  "Search is not guaranteed to return everything, and the index may change
  as your connection iterates through pages and databases."] Paginating to
  the end does not make the answer complete.
* **Lags recent changes.** [verified, same page: "Search indexing is not
  immediate. If a connection performs a search quickly after a page is
  shared with the connection (such as immediately after a user performs
  OAuth), then the response may not contain the page."] A page shared
  moments ago may simply not be here yet.
* Only pages and data sources **shared with this Notion connection** are
  visible at all.

One request per call. `has_more` / `next_cursor` are Notion's own; pass
`next_cursor` back as `cursor` to continue. Paginating to the very end does
NOT make the answer exhaustive — see the second bullet above. There is
deliberately no `complete` field here: `response_complete` says only that
Notion did not truncate this one response.
Parameter Type Required Description
query string Yes Text matched against TITLES ONLY. Notion's search does not look inside page bodies, so a phrase that appears in a page's content but not in its title will not be found.
object_type "page" | "data_source" | null No Restrict results to pages or to data sources. Omit for both. Note the value is `data_source`, not `database` — under API version 2026-03-11 a database's queryable contents are its data sources.
sort "relevance" | "last_edited_time" No `relevance` uses Notion's own ranking; `last_edited_time` sorts most-recently-edited first. Default: "relevance"
cursor string | null No Continuation cursor from a previous call's next_cursor.
page_size integer No Results per request. Notion's maximum is 100. Default: 50
Parameter schema (JSON)
{
  "properties": {
    "query": {
      "description": "Text matched against TITLES ONLY. Notion's search does not look inside page bodies, so a phrase that appears in a page's content but not in its title will not be found.",
      "title": "Query",
      "type": "string"
    },
    "object_type": {
      "anyOf": [
        {
          "enum": [
            "page",
            "data_source"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Restrict results to pages or to data sources. Omit for both. Note the value is `data_source`, not `database` — under API version 2026-03-11 a database's queryable contents are its data sources.",
      "title": "Object Type"
    },
    "sort": {
      "default": "relevance",
      "description": "`relevance` uses Notion's own ranking; `last_edited_time` sorts most-recently-edited first.",
      "enum": [
        "relevance",
        "last_edited_time"
      ],
      "title": "Sort",
      "type": "string"
    },
    "cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Continuation cursor from a previous call's next_cursor.",
      "title": "Cursor"
    },
    "page_size": {
      "default": 50,
      "description": "Results per request. Notion's maximum is 100.",
      "maximum": 100,
      "minimum": 1,
      "title": "Page Size",
      "type": "integer"
    }
  },
  "required": [
    "query"
  ],
  "title": "SearchParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "$defs": {
    "SearchHit": {
      "properties": {
        "id": {
          "title": "Id",
          "type": "string"
        },
        "object": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's own object type, verbatim (`page` or `data_source`).",
          "title": "Object"
        },
        "title": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Plain text of the title, or null when the object exposes none in this response.",
          "title": "Title"
        },
        "url": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Url"
        },
        "last_edited_time": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "title": "Last Edited Time"
        },
        "parent": {
          "anyOf": [
            {
              "additionalProperties": true,
              "type": "object"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Notion's `parent` object, verbatim.",
          "title": "Parent"
        }
      },
      "required": [
        "id"
      ],
      "title": "SearchHit",
      "type": "object"
    }
  },
  "properties": {
    "results": {
      "items": {
        "$ref": "#/$defs/SearchHit"
      },
      "title": "Results",
      "type": "array"
    },
    "has_more": {
      "description": "Notion's own `has_more` for this response, verbatim.",
      "title": "Has More",
      "type": "boolean"
    },
    "next_cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Pass back as `cursor` to fetch the next page.",
      "title": "Next Cursor"
    },
    "response_complete": {
      "description": "Whether Notion reported THIS ONE RESPONSE as untruncated: its own request_status, and true when Notion sent no request_status at all (absence of the field is not a report of truncation). A request_status that IS present but whose shape this toolkit does not recognize reports false — the weaker claim — rather than borrowing the absent-field default. **Deliberately not called `complete`** — the paginated tools use that name for provider-reported exhaustion, and this is a far weaker claim. It does NOT mean the search found everything that matches: Notion states search is not guaranteed to return everything and that its index lags recent sharing, so an exhaustive answer is not available from this tool at any setting, including when this field is true and has_more is false.",
      "title": "Response Complete",
      "type": "boolean"
    },
    "incomplete_reason": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own `request_status.incomplete_reason`, verbatim.",
      "title": "Incomplete Reason"
    },
    "searched": {
      "const": "titles only",
      "default": "titles only",
      "description": "Restated in the result so it survives into a transcript.",
      "title": "Searched",
      "type": "string"
    }
  },
  "required": [
    "results",
    "has_more",
    "response_complete"
  ],
  "title": "SearchResult",
  "type": "object"
}

# update_page_content write

Replace exact text in a Notion page, or replace the page's whole body. One edit per call; no concurrency protection.

Full description
Change a Notion page's existing content — one edit, or the whole body.

`PATCH /v1/pages/{page_id}/markdown` [verified —
developers.notion.com/reference/update-page-markdown], with the
`update_content` command for an edit and `replace_content` for a
replacement.

**Exactly one mode per call.** Sending fields from both, or from neither, is
rejected at parameter validation — before this tool makes any request at
all, unlike the property tools, which must read a schema first — naming the
fields at fault:

============================  ====================================================
Mode                          Fields
============================  ====================================================
Edit                          `find` + `replace`, optionally `replace_all`
Replacement                   `replace_entire_page=true` + `full_markdown` +
                              `confirm_replace_all=true`
============================  ====================================================

**One edit per call**, deliberately. Notion's command would accept a list of
edits, but a list means a call that half-applies, and then a caller has to
work out which half. With one edit there are two outcomes and no third: it
applied, or it did not. Sequence several edits as several calls, each with
its own definite answer.

**`find` matches what Notion stored, not what you sent.** Notion
canonicalizes Markdown as it writes, so the text a page was created with is
frequently not the text the page now holds — a Markdown pipe table, the
case observed in issue #2039, is stored as HTML and reads back as
`<table header-row="true">…<td>0.020</td>…`, not as `| … |` rows. Searching
for the Markdown you sent therefore fails on content you wrote yourself.
Read the page with `get_page` first and copy `find` out of the Markdown it
returns, character for character.

`find` must match the page's Markdown exactly. [verified, same page: "Each
`old_str` must match exactly one location in the page. If it matches
multiple locations, a `validation_error` is returned — set
`replace_all_matches: true` on that operation to replace all occurrences."]
Zero matches is likewise a `validation_error`. Both come back as a 400,
which means Notion rejected the request rather than partly applying it — so
**a rejected edit (400) leaves the page exactly as it was.** That is a claim
about the 400 family only, and deliberately not about failure in general: an
edit that ends in a 529, a 5xx, or any transport failure this toolkit cannot
prove happened before the request left — including one where it is simply
not known whether it left — is reported with `write_outcome_ambiguous: true`
and may or may not have been applied. On those, read the page back — do not
infer from this paragraph that a failure means nothing happened.

Replacement discards the whole body. Notion refuses to delete child pages or
databases in the process unless explicitly told to, and **this toolkit never
tells it to** — the `allow_deleting_content` option exists [verified, same
page: "Set to true to allow the operation to delete child pages or
databases. Defaults to false."] and is never sent, so a replacement that
would destroy a sub-page is refused by Notion listing what it would have
destroyed. There is no way to override that here.

**There is no concurrency control, and no version check.** Notion offers
none on this endpoint — no ETag, no precondition, no compare-and-swap — so a
change someone else made between your read and this write is silently
overwritten. An `expected_markdown_hash` parameter was considered and
deliberately dropped: it would read the page and then write, with nothing
enforcing anything in between, which is the same race wearing a name that
suggests it had been handled. Stating the absence is the honest option, and
this paragraph is it.

Notion offers an asynchronous mode for large content updates. This toolkit
does not use it, because a tool that returns before the write finishes
cannot report whether it succeeded. Content too large for one request is
refused with guidance to split it.
Parameter Type Required Description
page_id string Yes Page id or Notion URL.
find string | null No EDIT MODE. The exact existing text to replace. Must match the page's STORED Markdown exactly — Notion canonicalizes on write, so copy this out of what `get_page` returns rather than from the Markdown you sent. Requires `replace`. Cannot be combined with full_markdown, replace_entire_page or confirm_replace_all.
replace string | null No EDIT MODE. What `find` becomes. Pass an empty string to delete the matched text. Requires `find`.
replace_all boolean | null No EDIT MODE, optional. If the text appears more than once, replace every occurrence. Without it, a multiple match is an error and the page is left unchanged.
replace_entire_page boolean | null No REPLACEMENT MODE. Set true to replace the page's whole body. Requires full_markdown and confirm_replace_all=true.
full_markdown string | null No REPLACEMENT MODE. The page's complete new Markdown body. Everything currently in the page body is discarded.
confirm_replace_all boolean | null No REPLACEMENT MODE. Must be true. A second, explicit acknowledgement that the existing body is being discarded — deliberately separate from replace_entire_page so that replacing a page cannot happen through one mistyped flag.
Parameter schema (JSON)
{
  "description": "Two modes, and the validator below permits exactly one of them.\n\nEvery mode field is ``Optional[... ] = None`` rather than a defaulted\n``bool``, and that is what makes the rule enforceable at all: with\n``replace_all: bool = False`` there is no way to tell \"the caller did not\nmention it\" from \"the caller said false\", so a replacement-mode call that\nalso sent ``replace_all`` would be indistinguishable from a clean one. The\nanki precedent (``toolsets/anki/src/lambda_handler.py``, ``add_media_file``)\nuses the same device for the same reason.",
  "properties": {
    "page_id": {
      "description": "Page id or Notion URL.",
      "title": "Page Id",
      "type": "string"
    },
    "find": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "EDIT MODE. The exact existing text to replace. Must match the page's STORED Markdown exactly — Notion canonicalizes on write, so copy this out of what `get_page` returns rather than from the Markdown you sent. Requires `replace`. Cannot be combined with full_markdown, replace_entire_page or confirm_replace_all.",
      "title": "Find"
    },
    "replace": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "EDIT MODE. What `find` becomes. Pass an empty string to delete the matched text. Requires `find`.",
      "title": "Replace"
    },
    "replace_all": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "EDIT MODE, optional. If the text appears more than once, replace every occurrence. Without it, a multiple match is an error and the page is left unchanged.",
      "title": "Replace All"
    },
    "replace_entire_page": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "REPLACEMENT MODE. Set true to replace the page's whole body. Requires full_markdown and confirm_replace_all=true.",
      "title": "Replace Entire Page"
    },
    "full_markdown": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "REPLACEMENT MODE. The page's complete new Markdown body. Everything currently in the page body is discarded.",
      "title": "Full Markdown"
    },
    "confirm_replace_all": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "REPLACEMENT MODE. Must be true. A second, explicit acknowledgement that the existing body is being discarded — deliberately separate from replace_entire_page so that replacing a page cannot happen through one mistyped flag.",
      "title": "Confirm Replace All"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "UpdatePageContentParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "description": "The compact confirmation every content write returns.",
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "applied": {
      "default": true,
      "description": "True on every returned result. A content write that did not take effect raises an error instead of returning `applied: false`, so this is a restatement for a transcript, not a field to branch on.",
      "title": "Applied",
      "type": "boolean"
    },
    "content_length_chars": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Length of the page's Markdown AFTER the write, measured from the response Notion returns. Null if Notion sent no markdown back. The full text is deliberately not returned — use get_page for it.",
      "title": "Content Length Chars"
    },
    "content_truncated": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own `truncated` from the response, verbatim: the page now exceeds Notion's record limit (approximately 20,000 blocks) and a later read of it will be partial. Null when Notion did not report.",
      "title": "Content Truncated"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "ContentWriteResult",
  "type": "object"
}

# update_page_properties write

Set properties on a Notion page. Touches only the properties you name, and never the page's content.

Full description
Set one or more properties on an existing Notion page.

`PATCH /v1/pages/{page_id}` [verified —
developers.notion.com/reference/patch-page]. **Properties only — this never
touches the page's content.** [verified, same page: "To add content, use the
append block children API instead."] Use `append_content` or
`update_page_content` for the body.

Only the properties you name are sent, so this **narrows** the window in
which a concurrent edit can conflict, compared with rewriting a whole page.
That is a smaller blast radius, not a guarantee: it does not make the write
atomic with your read, and two callers setting the same property still race.
Notion provides no concurrency control on this endpoint — no ETag, no
version precondition, no compare-and-swap — so a concurrent change to a
property you also set is silently overwritten and nothing here detects it.

The page's parent decides what is writable [verified, same page: the
`properties` parameter "can only be used if the page's parent is a data
source, aside from updating the `title` of a page outside of a data
source"]. For a row, that is the data source's schema; for a plain page,
only the title. Read-only properties (formula, rollup, the created/edited
stamps, unique_id) are refused by name with that reason.

**Schema first, then coerce, then send.** A failure to resolve the schema, a
property that does not exist, an option that is not in the schema, or a
datetime with no offset all fail before the request — so those errors mean
the page was not modified. Where a rename has happened inside this toolkit's
schema-cache window, a Notion 400 against a cached schema is recovered from
once by re-reading the schema and rebuilding; the retry is safe because a
400 means Notion rejected the request rather than applying it
(**[inference, not a provider statement]**, grounded in RFC 9110 §15.5.1 and
argued in full at `_send_recovering_from_a_stale_schema`).
Parameter Type Required Description
page_id string Yes Page id or Notion URL.
title string | null No New page title. Convenience for the title property without needing its name. Giving it here AND in `properties` is an error.
properties object | null No Property values keyed by property NAME, in this toolkit's normalized form — same shapes as create_page. Only the properties you name are touched; the rest are left alone. Validated against the page's data-source schema, which is READ first, before any write is sent — so a rejection here means the page was not modified — and that schema may be a few minutes old, so a select/status option deleted or renamed in Notion very recently can still pass validation and be created anew by Notion on write.
raw_properties object | null No Notion's own property JSON, keyed by property name, sent UNVALIDATED. A name may not appear in both `properties` and here.
Parameter schema (JSON)
{
  "properties": {
    "page_id": {
      "description": "Page id or Notion URL.",
      "title": "Page Id",
      "type": "string"
    },
    "title": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "New page title. Convenience for the title property without needing its name. Giving it here AND in `properties` is an error.",
      "title": "Title"
    },
    "properties": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Property values keyed by property NAME, in this toolkit's normalized form — same shapes as create_page. Only the properties you name are touched; the rest are left alone. Validated against the page's data-source schema, which is READ first, before any write is sent — so a rejection here means the page was not modified — and that schema may be a few minutes old, so a select/status option deleted or renamed in Notion very recently can still pass validation and be created anew by Notion on write.",
      "title": "Properties"
    },
    "raw_properties": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Notion's own property JSON, keyed by property name, sent UNVALIDATED. A name may not appear in both `properties` and here.",
      "title": "Raw Properties"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "UpdatePagePropertiesParams",
  "type": "object",
  "additionalProperties": false
}
Result schema (JSON)
{
  "properties": {
    "page_id": {
      "title": "Page Id",
      "type": "string"
    },
    "updated_properties": {
      "description": "Every property name this call SENT, sorted; properties not named were not touched. Taken from the request this tool built, not from Notion's response — the call succeeded, so Notion accepted them, but this is not an echo of stored values. Read the page for those.",
      "items": {
        "type": "string"
      },
      "title": "Updated Properties",
      "type": "array"
    }
  },
  "required": [
    "page_id"
  ],
  "title": "UpdatePagePropertiesResult",
  "type": "object"
}