Skip to main content
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 (5.27.1).

At a glance

Start

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, [email protected]), no pages, and the built-in Baseline integration, which any token acts as (see Auth).

URL mapping

Point the official client at the emulator with baseUrl:

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 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:
@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.
  • 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:
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

  • 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

See CONFORMANCE.md for every provider.