> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Notion

> Pages, databases and data sources, queries, search, blocks, comments, OAuth, webhooks.

Stateful Notion API emulation: pages with typed property values and paged property items, databases and their data sources (the 2025-09-03 split, with 2022-06-28 served too), data source queries with the full filter grammar and sorts, search, blocks with nested children, comments, file uploads, and users, behind internal integration tokens with capabilities and sharing. The tests drive it with the official [`@notionhq/client`](https://github.com/makenotion/notion-sdk-js) (5.27.1).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/notion` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `notion` |
| HTTP operations | 45, browsable at `http://localhost:3999/docs/notion` while it runs ([API reference](/api-reference.md)) |
| Vendor spec | [makenotion/notion-mcp-server](https://github.com/makenotion/notion-mcp-server) at `730ae78` |

```bash CLI theme={null}
npx @deploybaseline/cli start --service notion
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const notion = await createEmulator({ service: "notion" });
// notion.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "notion": {
    "workspace": {
      "name": "Acme"
    },
    "users": [
      {
        "name": "Ada Lovelace",
        "email": "ada@example.com"
      }
    ],
    "integrations": [
      {
        "name": "Local integration",
        "token": "ntn_baseline_local"
      }
    ],
    "pages": [
      {
        "title": "Handbook",
        "content": ["Welcome to the handbook."]
      }
    ],
    "databases": [
      {
        "title": "Projects",
        "parent": "Handbook",
        "properties": {
          "Name": "title",
          "Status": {
            "select": ["Planned", "Active", "Done"]
          },
          "Budget": {
            "number": "dollar"
          }
        },
        "rows": [
          {
            "Name": "Apollo",
            "Status": "Active",
            "Budget": 1000
          }
        ]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service notion
```

Notion starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. Without a seed config the workspace (Baseline workspace) has one member (Admin, `admin@example.com`), no pages, and the built-in `Baseline` integration, which any token acts as (see [Auth](#auth)).

## URL mapping

| Real Notion URL | Emulator URL |
| - | - |
| `https://api.notion.com/v1/...` | `$NOTION_EMULATOR_URL/v1/...` |
| `https://www.notion.so/<Title>-<id>` (page and database URLs) | `$NOTION_EMULATOR_URL/<Title>-<id>` |

Point the official client at the emulator with `baseUrl`:

```ts theme={null}
import { Client } from "@notionhq/client";

const notion = new Client({
  auth: "ntn_baseline_local",
  baseUrl: process.env.NOTION_EMULATOR_URL,
});

const page = await notion.pages.create({
  parent: { page_id: "<page id>" },
  properties: { title: { title: [{ text: { content: "Meeting notes" } }] } },
});
```

## Auth

Send `Authorization: Bearer <token>`.

* **Internal integration tokens** come from `integrations` in the seed. Each acts as its integration's bot user with its capabilities (`read_content`, `update_content`, `insert_content`, `read_comments`, `insert_comments`, and a user information level) and sees only what is shared with it: the `shared` pages and databases and everything under them, plus what it creates. A missing capability gets HTTP 403 `restricted_resource`; anything not shared gets HTTP 404 `object_not_found` with Notion's `Make sure the relevant pages and databases are shared with your integration.`
* **OAuth access tokens** come from the [OAuth flow](#oauth) of a public integration. Each acts as that integration's bot user for the workspace, with its capabilities and every page.
* **Any other token** the CLI accepts acts as the built-in `Baseline` integration, with every capability and the whole workspace, so a quick start needs no setup.

A missing or unknown token gets HTTP 401 `unauthorized` (`API token is invalid.`).

## Errors

Errors use Notion's body: `{ "object": "error", "status", "code", "message", "request_id" }`. Every response carries a `request_id`. Body checks return HTTP 400 `validation_error` with Notion's wording, such as ``body failed validation: body.children[0].code.language should be a supported language, instead was `"klingon"`.``, `Budget is expected to be number.`, or `Nope is not a property that exists.`. Unknown `/v1` paths get HTTP 400 `invalid_request_url`.

Notion's rate limit is three requests a second on average, answered with HTTP 429 `rate_limited` and `Retry-After`. The emulator never limits on its own; to test a client's handling, inject Notion's response with [fault injection](/configuration.md#fault-injection):

```json theme={null}
{
  "notion": {
    "faults": {
      "seed": 1,
      "rules": [
        {
          "match": "POST /v1/pages",
          "rate": 0.3,
          "response": {
            "status": 429,
            "headers": {
              "Retry-After": "1"
            },
            "body": {
              "object": "error",
              "status": 429,
              "code": "rate_limited",
              "message": "This request exceeds the number of requests allowed. Slow down and try again."
            }
          }
        }
      ]
    }
  }
}
```

`@notionhq/client` retries 429 responses on its own, honoring `Retry-After`; pass `retry: false` to see them.

## Versions

Every request needs a `Notion-Version` header, as on Notion (HTTP 400 `missing_version` without one). The emulator serves:

* **`2025-09-03`**, the version `@notionhq/client` 5 sends by default. Databases are containers of data sources; the schema, rows, and queries live on `/v1/data_sources`, and a database lists its data sources.
* **`2022-06-28`** and the versions before it, answered the 2022-06-28 way: databases carry `properties`, `POST /v1/databases` takes `properties`, `PATCH /v1/databases/{id}` changes the schema of the database's data source, and rows have a `database_id` parent.

Any other version gets HTTP 400 `validation_error`.

## Pages

* `POST /v1/pages` — Create page: under a page, a data source (`data_source_id`), a database with one data source (`database_id`), or a block, with `properties`, `icon`, `cover`, `position`, and content as `children` (or `content`) blocks or a `markdown` string (headings, lists, to-dos, quotes, code fences, dividers, and inline bold, italic, code, strikethrough, and links). `template` takes `none` or `default`; data sources have no templates.
* `GET /v1/pages/{id}` — Get page: with `filter_properties`. Relation, people, rich text, and title values show at most 25 entries, with `has_more` on relations.
* `PATCH /v1/pages/{id}` — Update page: `properties`, `icon`, `cover`, `is_locked`, `in_trash` (or `archived`), and `erase_content`. Pages in the trash, or under a page in the trash, cannot be edited until restored.
* `POST /v1/pages/{id}/move` — Move page: to another page or data source.
* `GET /v1/pages/{id}/properties/{property_id}` — Get page property: a paged `property_item` list for title, rich text, people, relation, and list rollups (`page_size` up to 100, `start_cursor`), a single property item otherwise.

Property values are checked by type as Notion checks them: rich text items (text, mentions, equations) with the 2000 character and 100 item limits, numbers, select and multi-select (new options are added to the schema, commas refused), status (only existing options), dates with time zones, people, files, checkbox, URL, email, phone, and relations (up to 100 per write). Created and edited times and users, unique ids, and rollups are computed; relations with a dual property stay in sync on both sides. Formulas keep their expression but are not evaluated (their value is an empty string).

## Databases and data sources

* `POST /v1/databases` — Create database: with `initial_data_source.properties` (or `properties` under 2022-06-28), `title`, `description`, `is_inline`, `icon`, and `cover`. Exactly one title property is required.
* `GET`/`PATCH /v1/databases/{id}`: title, description, inline, icon, cover, lock, trash, and parent.
* `POST /v1/data_sources` — Create data source: adds a data source to a database. `GET`/`PATCH /v1/data_sources/{id}` read and change the title, icon, trash state, and schema: add a property, rename it with `name`, change its type, or remove it with `null`.
* `GET /v1/data_sources/{id}/templates` — List data source templates: returns an empty list.

Every property type is accepted in a schema: title, rich text, number (with Notion's formats), select, multi-select, status (default options and groups), date, people, files, checkbox, URL, email, phone, formula, relation (single or dual, with the synced property created on the other side), rollup (every function), created and edited time and by, button, unique id (with a prefix), place, location, and verification.

## Queries

* `POST /v1/data_sources/{id}/query` — Query data source (and `POST /v1/databases/{id}/query` under 2022-06-28, which reads the database's first data source) takes `filter`, `sorts`, `start_cursor`, `page_size` (up to 100), `in_trash` (also include rows in the trash), and `filter_properties` in the query string. Results are a `page_or_data_source` list (`page_or_database` under 2022-06-28); without sorts the newest rows come first.

Filters follow Notion's grammar:

* **Property filters** name a property by name or id and use its type as the key, such as `{ "property": "Points", "number": { "greater_than": 4 } }`. A type that does not match the property gets `database property number does not match filter checkbox`; an unknown property gets `Could not find property with name or id: X`.
* **Conditions** for every filterable type: text (`title`, `rich_text`, `url`, `email`, `phone_number`: `equals`, `does_not_equal`, `contains`, `does_not_contain`, `starts_with`, `ends_with`; matching other than `equals` ignores case), `number` and `unique_id` (`equals`, `does_not_equal`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`), `checkbox`, `select` and `status` (`equals` and `does_not_equal`, with a name or a list of names), `multi_select` (`contains`, `does_not_contain`), `date`, `created_time`, and `last_edited_time` (`equals`, `before`, `after`, `on_or_before`, `on_or_after` with an ISO date, date and time, or `today`, `tomorrow`, `yesterday`, `one_week_ago`, `one_week_from_now`, `one_month_ago`, `one_month_from_now`; `past_week`, `past_month`, `past_year`, `next_week`, `next_month`, `next_year`, `this_week`), `people`, `created_by`, and `last_edited_by` (`contains`, `does_not_contain`, with a user id or `"me"`), `relation`, `files`, `formula` (`string`, `checkbox`, `number`, `date`), `rollup` (`any`, `every`, `none` with a subfilter, or `number` and `date`), and `verification` (`status`, `does_not_equal`). Every type except checkbox and verification also takes `is_empty: true` and `is_not_empty: true`.
* **Timestamp filters**: `{ "timestamp": "created_time", "created_time": { "past_week": {} } }`, and the same for `last_edited_time`.
* **Compound filters**: `{ "and": [...] }` and `{ "or": [...] }`, nested at most two levels deep, as on Notion.

Dates compare by calendar day (UTC) when the filter gives a date, and by instant when it gives a time. A date range compares by its start. Weeks start on Sunday.

Sorts are a list of `{ "property", "direction" }` or `{ "timestamp", "direction" }`, applied in order. Select and status values sort by option order, and empty values sort last in either direction.

## Search

* `POST /v1/search` — Search: matches `query` against page titles and data source titles (database titles under 2022-06-28), ignoring case, over everything the integration can see. `filter` takes `{ "property": "object", "value": "page" }` or `"data_source"` (`"database"` under 2022-06-28) and `in_trash`; `sort` takes `{ "timestamp": "last_edited_time", "direction" }` (the default is newest first). Results page by `start_cursor` and `page_size`.

## Blocks

* `GET /v1/blocks/{id}` — Get block
* `GET /v1/blocks/{id}/children` — List block children: `page_size` up to 100, `start_cursor`.
* `PATCH /v1/blocks/{id}/children` — Append block children: up to 100 blocks, nested two levels deep, at the end, `after` a block, or at a `position`. Tables need rows as wide as `table_width`; column lists need two columns or more.
* `PATCH /v1/blocks/{id}` — Update block: a block's content for its type, or `in_trash`.
* `DELETE /v1/blocks/{id}` — Delete block: moves it to the trash. Child pages and child databases can only be trashed this way.

Block types: paragraphs, three heading levels (toggleable), bulleted and numbered list items, to-dos, toggles, quotes, callouts, code (with Notion's languages), dividers, equations, tables and rows, column lists and columns, bookmarks, embeds, link previews, images, videos, audio, files, PDFs, table of contents, breadcrumbs, synced blocks, and link to page.

## Comments

* `POST /v1/comments` — Create comment: starts a discussion on a page (`parent.page_id`) or block (`parent.block_id`), or replies to one (`discussion_id`), with `rich_text` or `markdown`, up to three `attachments` (`file_upload_id`), and a `display_name` (`integration`, the default; `custom` with a name; `user`, for public integrations acting for a user).
* `GET /v1/comments?block_id=` — List comments: on a page or block, oldest first, with cursor paging.
* `GET`, `PATCH` (`rich_text` or `markdown`), and `DELETE /v1/comments/{id}`. An integration may edit and delete only its own comments.

Reading needs the read comments capability and writing the insert comments capability.

## File uploads

* `POST /v1/file_uploads` — Create file upload: with `mode` `single_part` (the default), `multi_part` (`filename` with an extension and `number_of_parts` up to 1000), or `external_url` (an https URL), plus `filename` and `content_type`. The response has `upload_url` (and `complete_url` for multi part uploads) on the emulator.
* `POST /v1/file_uploads/{id}/send` — Send file upload: takes multipart form data with `file` and, for multi part uploads, `part_number`. Single part files and parts are at most 20 MB, and every part but the last is at least 5 MB. A file whose type differs from the upload's `content_type` is refused.
* `POST /v1/file_uploads/{id}/complete` — Complete file upload: finishes a multi part upload once every part is sent.
* `GET /v1/file_uploads/{id}` — Get file upload: one of the integration's own uploads.
* `GET /v1/file_uploads` — List file uploads: `status`, cursor paging; the integration's own uploads.

Attach an uploaded file by id as `{ "type": "file_upload", "file_upload": { "id": "..." } }` in an icon, a cover, an image, video, audio, file, or PDF block, a files property value, or a comment attachment. Pages then return a Notion hosted file, `{ "type": "file", "file": { "url", "expiry_time" } }`, served by the emulator at `/_notion/files/{id}/{name}` until its URL expires an hour later (HTTP 403 after). An upload not attached within an hour of creation expires. External URL imports finish at once, since the emulator runs without network access, and their hosted URL redirects to the original.

## OAuth

Public integrations come from `oauth_integrations` in the seed, each with a `client_id`, `client_secret`, `redirect_uris`, and capabilities. With none seeded, any client id is accepted: it is registered on first use with every capability, and the first client secret sent to the token endpoint sticks.

* `GET /v1/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&owner=user&state=...` — Authorize: shows a consent page listing the integration's capabilities and the workspace members. Choosing a member redirects to `redirect_uri` with `code` and `state`; Cancel redirects with `error=access_denied`. A `redirect_uri` that matches none of the integration's gets an error page.
* `POST /v1/oauth/token` — Exchange token: takes the client id and secret as HTTP Basic auth and a JSON body: `{ "grant_type": "authorization_code", "code", "redirect_uri" }` (the same `redirect_uri` the authorize step had; codes are single use and last ten minutes) or `{ "grant_type": "refresh_token", "refresh_token" }`. The answer has `access_token`, `token_type: "bearer"`, `refresh_token`, `bot_id`, `workspace_id`, `workspace_name`, `workspace_icon`, `owner` (the member who added it), and `duplicated_template_id: null`. A refresh issues a new pair and ends the old one. Each integration has one bot user per workspace, whoever installs it.
* `POST /v1/oauth/introspect` — Introspect token: with `{ "token" }` answers `{ active, scope, iat }`, where `scope` lists the capabilities.
* `POST /v1/oauth/revoke` — Revoke token: with `{ "token" }` ends it (revoking a refresh token also ends its access token). Revoked tokens get HTTP 401.

Errors use the OAuth form, `{ "error", "error_description", "request_id" }`: `invalid_client` (HTTP 401) for missing or wrong credentials, `invalid_grant` for a bad, used, or expired code or refresh token or a different `redirect_uri`, and `unsupported_grant_type`. The client's `client.oauth.token`, `introspect`, and `revoke` work unchanged.

## Users

* `GET /v1/users` — List users: paged
* `GET /v1/users/{id}` — Get user
* `GET /v1/users/me` — Get bot user: returns the integration's bot with its workspace

Emails show only with the `with_email` user information level; with `none`, the list and retrieve endpoints answer HTTP 403.

## Webhooks

Notion sets webhook subscriptions up in an integration's settings, so the emulator takes them from the seed or a local control endpoint:

* `webhooks` on an entry in `integrations` or `oauth_integrations`: `url`, `events` (all when left out), `api_version` (`2025-09-03` by default, or `2022-06-28`), and `verification_token`. With a token the subscription starts verified; without one the emulator sends the verification request at startup.
* `POST /_notion/webhooks` — Create webhook subscription: with `{ "integration": <name, id, token, or client id>, "url", "events", "api_version" }` creates a subscription and sends `{ "verification_token": "secret_..." }` to its URL, as Notion does. The answer has `subscription_id`, `status: "pending"`, and `verification_request_status`. Confirm with `POST /_notion/webhooks/{subscription_id}/verify` and `{ "verification_token" }`; remove with `DELETE /_notion/webhooks/{subscription_id}`.

Verified subscriptions receive the events a request causes once it ends: `page.created`, `page.content_updated`, `page.properties_updated`, `page.moved`, `page.deleted`, `page.undeleted`, `page.locked`, `page.unlocked`, `database.*` and `data_source.*` (`created`, `moved`, `deleted`, `undeleted`, `content_updated`, `schema_updated`), and `comment.created`, `comment.updated`, `comment.deleted`. Each delivery is one event:

```json theme={null}
{
  "id": "...", "timestamp": "...", "workspace_id": "...", "workspace_name": "Acme",
  "subscription_id": "...", "integration_id": "...", "type": "page.created",
  "authors": [{ "id": "...", "type": "bot" }], "attempt_number": 1,
  "entity": { "id": "...", "type": "page" },
  "data": { "parent": { "id": "...", "type": "page" } },
  "api_version": "2025-09-03"
}
```

Events reach an integration only for entities it can see and with the matching capability (read content, or read comments for comment events); a public integration hears them once installed. Changes of one type to one entity in a request are merged, with `updated_blocks` and `updated_properties` joined. A `2025-09-03` subscription gets `data_source.content_updated` and `data_source.schema_updated` and parents like `{ "type": "database", "id", "data_source_id" }`; a `2022-06-28` subscription gets `database.content_updated` and `database.schema_updated` for a database's first data source instead, and no other `data_source.*` events.

Every event carries `X-Notion-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw body keyed by the verification token; check it with the client's `verifyWebhookSignature`. Any 2xx answer counts as delivered. Other answers and network errors are retried up to eight times, after 1, 2, 4, 8, 16, and 32 seconds and then twice a minute apart (Notion spreads its retries over about a day).

## Seed config

```json theme={null}
{
  "notion": {
    "workspace": {
      "name": "Acme"
    },
    "users": [
      {
        "name": "Ada Lovelace",
        "email": "ada@example.com"
      }
    ],
    "integrations": [
      {
        "name": "Local integration",
        "token": "ntn_baseline_local",
        "capabilities": [
          "read_content",
          "update_content",
          "insert_content",
          "read_comments",
          "insert_comments"
        ],
        "user_info": "with_email",
        "shared": ["Handbook"],
        "webhooks": [
          {
            "url": "http://localhost:3000/notion/webhook",
            "events": ["page.created", "page.properties_updated"],
            "api_version": "2025-09-03",
            "verification_token": "secret_local"
          }
        ]
      }
    ],
    "oauth_integrations": [
      {
        "name": "Planner",
        "client_id": "planner",
        "client_secret": "planner-secret",
        "redirect_uris": ["http://localhost:3000/callback"],
        "capabilities": ["read_content", "insert_content"],
        "user_info": "with_email"
      }
    ],
    "pages": [
      {
        "title": "Handbook",
        "icon": "H",
        "content": [
          "Welcome to the handbook.",
          {
            "heading_1": "Policies"
          },
          {
            "to_do": {
              "rich_text": [
                {
                  "text": {
                    "content": "Laptop"
                  }
                }
              ],
              "checked": false
            }
          }
        ]
      },
      {
        "title": "Engineering",
        "parent": "Handbook"
      }
    ],
    "databases": [
      {
        "title": "Projects",
        "parent": "Handbook",
        "inline": false,
        "properties": {
          "Name": "title",
          "Budget": {
            "number": "dollar"
          },
          "Stage": {
            "select": ["Planned", "Active", "Done"]
          }
        },
        "rows": [
          {
            "Name": "Apollo",
            "Budget": 1000,
            "Stage": "Active"
          }
        ]
      },
      {
        "title": "Tasks",
        "properties": {
          "Task": "title",
          "Done": "checkbox",
          "Owner": "people",
          "Project": {
            "relation": {
              "database": "Projects",
              "dual": "Tasks"
            }
          },
          "Budget": {
            "rollup": {
              "relation": "Project",
              "property": "Budget",
              "function": "sum"
            }
          }
        },
        "rows": [
          {
            "Task": "Write spec",
            "Done": true,
            "Owner": ["ada@example.com"],
            "Project": "Apollo"
          }
        ]
      }
    ]
  }
}
```

* `capabilities`: all when left out
* `user_info`: none, without\_email, or with\_email (the default)
* `shared`: page or database titles or ids; the whole workspace when left out
* `events`: all when left out
* `api_version`: or "2022-06-28"
* `verification_token`: verified at once; left out, the handshake runs at startup
* `icon`: an emoji, or an image URL
* `content` items: a string is a paragraph, `{ "heading_1": "Policies" }` is a block type with its text, or give a whole block object (the `to_do` item)
* `parent`: top level when left out

Properties take a type name, or a type with its options: `{ number: <format> }`, `{ select: [...] }`, `{ multi_select: [...] }`, `{ status: [...] }`, `{ formula: <expression> }`, `{ unique_id: <prefix> }`, `{ relation: <database title> }` or `{ relation: { database, dual } }`, and `{ rollup: { relation, property, function } }`. Any other value is passed through as Notion's property configuration. Row values are plain: text, numbers, booleans, option names, dates, user emails, file URLs, and related row titles. Seeds are checked as the API checks writes, so a bad seed stops the emulator with a message naming the entry. Seeding again does not duplicate pages, databases, or rows (matched by title).

## Inspector

Open the emulator's root URL for tabs listing pages, databases with their data sources, schemas, and row counts, comments and file uploads, users and integrations with their tokens, capabilities, and what is shared with them, and OAuth integrations, webhook subscriptions with their verification tokens, and recent deliveries with each attempt. Page and database URLs from the API open a view of the page's content and properties, or the database's rows.

## Limits

* Formulas are not evaluated, so formula filters see an empty string. Data source templates, views, and wiki verification are not modeled.
* Search matches title substrings and has no relevance ranking: `sort: { property: "relevance" }` is accepted and sorts by last edit.
* Ids are accepted with or without dashes. Times are rounded down to the minute, as Notion reports them.

## Conformance

| | |
| - | - |
| Routes | 49 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | fail, 0 allowed ([makenotion/notion-mcp-server](https://github.com/makenotion/notion-mcp-server) at `730ae78`) |
| SDKs tested | `@notionhq/client@5.27.1` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.