Overview
The Notion API supports reading, writing, and updating page content using enhanced markdown as an alternative to the block-based API. This is especially useful for agentic systems and developer tools that work natively with markdown. Three API surfaces are available:Markdown versions
The markdown endpoints accept an optionalmarkdown_version parameter. It picks the Markdown syntax for the request.
Leave
markdown_version out, or set it to v1, to keep today’s behavior. Set it to v2 to read and write Notion-flavored Markdown. The version applies to the whole request: v2 content is parsed as v2, and v2 responses are rendered as v2.
Where to send it:
Any other value returns a
400 validation_error. On POST /v1/pages and the comment endpoints, markdown_version only applies to markdown. Sending it without markdown (for example, with children or rich_text) also returns a 400 validation_error.
See the Notion-flavored Markdown format reference for the full v2 syntax. It’s generated from the same specification the Notion MCP server gives AI agents.
cURL
Transcripts in v2
v2 puts a meeting note’s transcript in a <meeting-notes-section-transcript> tag inside <meeting-notes>. Without include_transcript=true, the tag holds a placeholder that starts with Transcript omitted. and says how to load the transcript.
Update warnings
Av2 update can contain syntax that the parser doesn’t support, such as an unknown tag. The update still runs, and the PATCH /v1/pages/:page_id/markdown response includes a warnings array that says what the parser changed or ignored. In either version, an update_content edit whose old_str matches nothing is skipped while the matching edits apply, and each skipped edit appears in warnings with code no_match, or indentation_mismatch when the text is on the page with different indentation:
code is the warning category. The field is left out when there are no warnings, and other endpoints never return it. Async updates (allow_async: true) don’t return warnings in the task result.
Block type support
The markdown API supports most Notion block types. The table below shows how each block type maps to its markdown representation.Supported block types
For file-based blocks (image, file, video, audio, PDF), the URLs in the markdown output are pre-signed and ready to download. They expire after a short period, consistent with the block-based API.
Custom blocks
Place an instance of an existing custom block definition with<custom-block definition-id="UUID" /> on its own line. The instance inherits the definition’s bindings. New placements and definition changes require a live, readable definition in the destination workspace; they do not require separate workspace enablement. Definition creation remains in private alpha.
Readback returns the definition reference, not the rendered contents. These instances previously appeared as <unknown> and were included in unknown_block_ids; they now use the custom-block tag. Instances with binding overrides still appear as <unknown>.
An unchanged instance can survive update_content or replace_content even when the connection cannot read its definition or the definition was deleted. Adding another instance or changing its definition still requires authorization. Public requests reject definition-key, which is reserved for App deployments.
Unsupported block types
The following block types are not yet rendered in the markdown output. When encountered, they appear as<unknown url="..." alt="block_type"/> tags. The url links to the block in Notion, and alt indicates the original block type.
Block types that are not recognized by the block API (returned as
"unsupported") will also appear as <unknown> in the markdown output.
Creating a page with markdown
UsePOST /v1/pages with the markdown parameter instead of children to create a page from a markdown string.
- The
markdownparameter is mutually exclusive withchildrenandcontent. You cannot use both. - If
properties.titleis omitted, the first# h1heading is extracted as the page title. - Available to all connection types (public, internal, and personal access tokens).
- Requires
insert_contentandinsert_propertycapabilities. - Markdown that is too large or too heavily formatted to parse in one request returns a
validation_error. So does markdown that would create more than 5,000 blocks, counting nested blocks. Withallow_async: true, the request still returns anasync_task, and the task ends asfailedwith thatvalidation_errorwhen you poll it. Retrying the same body fails the same way. Create the page with part of the content and add the rest with further requests, or split the content into child pages.
Retrieving a page as markdown
UseGET /v1/pages/:page_id/markdown to retrieve a page’s content as markdown. It uses enhanced markdown unless you set markdown_version=v2.
\n) between adjacent top-level blocks. Line breaks inside a single block are represented as <br>.
Query parameters
- Available to all connection types (public, internal, and personal access tokens).
- Requires
read_contentcapability. - File URIs in the content are automatically converted to pre-signed URLs.
Unknown blocks, truncation, and permissions
Some blocks in a page may appear as<unknown> tags in the markdown output. This can happen for two reasons:
- Truncation — the page exceeds the record limit (approximately 20,000 blocks) and some blocks were not loaded.
- Permissions — the page contains child pages or other content that is not shared with the connection. The connection can access the parent page, but not those specific child blocks.
- The
truncatedfield is set totrue. - The affected blocks appear as
<unknown url="..." alt="..."/>tags in the markdown. - The
unknown_block_idsarray contains the IDs of these blocks.
object_not_found error — the connection does not have access to that content.
The
unknown_block_ids array does not distinguish between truncated and inaccessible blocks. When re-fetching unknown block IDs, handle object_not_found errors gracefully as they indicate blocks the connection cannot access.Updating a page with markdown
UsePATCH /v1/pages/:page_id/markdown to insert or replace content in an existing page using markdown.
The request body uses a discriminated union with four command variants. We recommend update_content and replace_content for new connections — they offer more precise control and better performance than the older insert_content and replace_content_range commands.
Updating content with search-and-replace
Useupdate_content to make targeted edits with an array of search-and-replace operations. Each operation specifies old_str (content to find) and new_str (replacement content).
old_str must be a non-empty string that matches 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.
Replacing all page content
Usereplace_content to replace the entire page content with new markdown.
Legacy commands
The
insert_content and replace_content_range commands are still supported but are no longer recommended. They use an ellipsis-based selection format that is less precise than the search-and-replace approach of update_content. New connections should use update_content or replace_content instead.insert_content (legacy)
insert_content (legacy)
Insert new markdown content at the start of a page, after a specific point in the page, or at the end.The
position parameter supports { "type": "start" } and { "type": "end" }. When both position and after are omitted, content is appended to the end of the page, preserving the existing behavior.The after parameter uses an ellipsis-based selection format: "start text...end text". This matches a range from the first occurrence of the start text to the end text. Do not provide after and position in the same request.replace_content_range (legacy)
replace_content_range (legacy)
Replace a matched range of existing content with new markdown.The
content_range parameter uses the same ellipsis-based selection as after.Safety: protecting child pages and databases
By default, the update endpoint refuses to delete child pages or databases. If an operation would delete them, avalidation_error is returned listing the affected items.
To allow deletion, set allow_deleting_content: true in the command body. This option is supported by replace_content_range, update_content, and replace_content:
Update response
All variants return the full page content as markdown after the update:- Available to all connection types (public, internal, and personal access tokens).
- Requires
update_contentcapability. - The
content_range/after/old_strmatching is case-sensitive.
Error responses
Meeting note transcripts
The update endpoint always skips meeting note transcript content, matching the default behavior of the GET endpoint. If you retrieve a page withinclude_transcript=true, the transcript text will appear in the response but cannot be used in content_range or after selections — the update endpoint does not see transcript content during matching and will return a validation_error for selections that span transcript text.
Running large markdown writes asynchronously
Create and update requests with large markdown bodies can take longer than typical HTTP client, browser, or edge timeout budgets. For those writes, setallow_async: true to receive an async_task handle and poll for completion.
Async support is available for:
Requests that omit
allow_async, or set it to false, keep the existing synchronous response behavior. allow_async changes response behavior only; it does not change validation, permissions, or the operation being performed. Notion does not automatically convert a synchronous request into an async response.
Async task completion is polling-first in this version. Use
poll_after_seconds from the task response as the minimum delay before polling again. Webhook notifications and ETA estimates are not part of the async task contract.REST: Create a page asynchronously
Setallow_async: true on POST /v1/pages when creating a page from markdown:
202:
REST: Update markdown asynchronously
Setallow_async: true at the top level of the PATCH /v1/pages/:page_id/markdown request body:
async_task object with a status_url to poll. A 202 response means Notion accepted the request for background execution after initial request and access checks. Markdown parsing, content matching, and other update validation can still fail while the task runs, so always poll until the task reaches succeeded or failed.
Poll for completion
Poll the task’sstatus_url, or call Retrieve an async task with the returned id:
pages.create() and pages.updateMarkdown() accept allow_async: true, and asyncTasks.retrieve() polls the returned task:
retrying, Notion is retrying a retryable infrastructure or downstream-service failure. Continue polling with the returned poll_after_seconds guidance. If a task reaches failed, inspect the error before retrying; validation and permission failures usually require a corrected request.
Completed and failed task metadata is retained for a bounded period. After expiry, polling the task returns the standard not-found response, so store any final result data your application needs.
For the full status resource, see Retrieve an async task.
MCP async examples
For Notion MCP, passallow_async: true to create_pages or update_page. The tool descriptions recommend this for most page writes unless the next step needs the created or updated page right away, or the async attempt is rejected as too large to queue. This is guidance to assistants, not a request default. When allow_async is omitted, create_pages keeps its synchronous behavior, and update_page waits for a synchronous result when it can but can still return an async_task if the work runs past the synchronous wait deadline.
notion-get-async-task:
queued, running, retrying, succeeded, and failed. When the task succeeds, the response includes the create or update result. When it fails, the response includes an error object. Wait for succeeded before any step that depends on the new content.