Turn a Notion page or database into a PDF from the command line
Notion's own export makes a PDF of one page at a time, by hand, with Notion's layout. If you need PDFs of many pages, a PDF every time a row is ready, or a document in your own theme with page numbers and a contents page, the API route is two HTTP calls: Notion's Retrieve a page as markdown endpoint returns the page content as Markdown, and https://papertype.dev/v1/pdf turns Markdown into a PDF. The catch is that Notion's Markdown is its own dialect (XML-like tags for callouts, toggles and columns, {color="…"} attribute lists, one newline between blocks, tab indentation). This page gives you a script (papertype-notion.py, Python 3.9+, standard library only, MIT) that does both calls and converts the dialect in between. The script was executed end to end on 2026-10-23 with Notion's responses replaced by stubs shaped like Notion's documentation and everything on our side live; it was not run against a Notion workspace, so the Notion steps below are quoted from Notion's developer documentation fetched the same day.
Setup: a Notion token, a PaperType key, one command
Create a free API key at /signup. On the Notion side, quoting Notion's API quickstart (developers.notion.com, 2026-10-23):
- Personal access token. Open Personal access tokens in the Developer portal (
notion.so/developers/tokens) → New token → enter a name, select the Notion API capability, Create token; copy it, it is shown once. A PAT "uses the workspace membership and page permissions of the person who created it" and "does not need pages to be shared with a bot through Add connections", so it reads every page you can read. Notion's page adds that on Business and Enterprise plans PAT creation is off by default and a workspace owner enables it under Settings → Connections. - Or an internal connection (for a team-owned job that should not depend on one person): its token comes from the connection's Configuration tab, and Notion's guide says a new connection "has no page access by default": share each page (or the database) with it via the page's Add connections menu or the connection's Content access tab. The markdown endpoint also needs the connection's read content capability, otherwise it returns 403.
- Page id. Copy the page link in Notion; Notion's docs say the URL ends in the page id, a 32-character string. The script accepts the whole URL (it takes the last 32-character id, so a database view link with
?v=…also works), the bare id, or the dashed form.
export NOTION_TOKEN=ntn_... # personal access token or internal-connection token
export PAPERTYPE_API_KEY=dp_... # from /signup
curl -sO https://papertype.dev/static/notion/papertype-notion.py
python3 papertype-notion.py "https://www.notion.so/acme/Q4-launch-plan-1429989fe8ac4effbc8f57f56486db54"
# Q4-launch-plan.pdf 3 page(s)
python3 papertype-notion.py <page> --theme report --toc --page-size Letter # options
python3 papertype-notion.py <page> --url # print a 24-hour download link instead of savingWhat the script sends to Notion, straight from Notion's reference: GET https://api.notion.com/v1/pages/{id} for the title and properties, then GET https://api.notion.com/v1/pages/{id}/markdown, both with Authorization: Bearer ntn_… and the required Notion-Version: 2026-03-11 header. The markdown response is {"object": "page_markdown", "markdown": "…", "truncated": false, "unknown_block_ids": []}. Notion's docs say the endpoint is available to personal access tokens and both connection types. The script then posts to https://papertype.dev/v1/pdf:
{
"markdown": "# Q4 launch plan\n\n| Property | Value |\n|---|---|\n| Owner | Jane Doe |\n| Due | 2026-11-15 |\n\n# Project kickoff\n\nGoals for the quarter.\n\n> Ship the MVP by **Friday**.\n\n- ☑ Write spec\n- ☐ Build prototype\n…",
"title": "Q4 launch plan",
"theme": "clean",
"page_size": "A4",
"toc": false,
"page_numbers": true,
"filename": "Q4-launch-plan.pdf",
"output": "pdf"
}Live result on 2026-10-23 for the test page (title, ten properties, Notion's documented sample content): 200, X-Pages: 3, saved as Q4-launch-plan.pdf. The page's title is a Notion property, not part of the content, so the script puts it in as the first heading; a database page's other properties become a Property | Value table under it (--no-properties to skip).
What the script does to Notion's markdown
Notion's reference for its enhanced markdown (fetched 2026-10-23) lists the syntax; the renderer reads CommonMark with GFM tables, so notion_to_markdown() rewrites the Notion-only parts. Everything below is exercised by the script's test:
- Block spacing. Notion's docs: "Retrieved markdown uses a single newline between adjacent top-level blocks." In CommonMark two lines with no blank line between them are one paragraph, so the script inserts blank lines between blocks, keeping list items and table rows together.
- Attribute lists such as
# Heading {color="blue"}and{toggle="true"}are removed;<span color="red">becomes an inline colour,<span underline="true">an underline. - Callouts (
<callout icon="…">with tab-indented children) become a block quote, one line per child block. Emoji icons are dropped: the renderer's fonts (DejaVu, Liberation, Noto, Carlito, Caladea) have no colour emoji, so a 🎯 would print as a box; emoji inside your text will too. - Toggles (
<details><summary>) print the title in bold followed by the children, open. Columns are flattened top to bottom. Synced blocks keep their content. - To-dos
- [ ]/- [x]become ☐ / ☑ list items. Tables arrive either as pipe tables or as<table>HTML; both render, with Notion's colour attributes removed. - Mentions and references.
<mention-user>,<mention-page>,<page url>and<database url>become links with the same text;<mention-date>prints the date. Child pages are therefore a link, not their content: run the script once per page for those. - Images. The renderer has no network access. Images, fonts and stylesheets at external URLs are never fetched; inline them as
data:URIs. A request that references an external URL fails with a 422 that names the URL, rather than silently dropping the asset. Notion's file reference says a Notion-hosted file URL is "valid for one hour", so the script downloads each image while the link is fresh and inlines it as adata:URI, up to about 1.4 MB of encoded images per document (2 MB of source per request); larger or further images print as a link line.--no-imagesskips the downloads. <table_of_contents/>on the page switches on the API'stocoption, which adds a contents page;--tocdoes the same. Equations ($$) are not rendered and print as their source. Unsupported blocks (Notion's docs name bookmarks, embeds and link previews) arrive as<unknown url="…"/>and print as an italic link.- Truncated pages. Notion's docs: pages over roughly 20,000 blocks return
truncated: truewithunknown_block_ids; the script fetches each listed id once and appends it, and skips ids that return 404 (blocks not shared with the token).
A whole database: one PDF per row
Notion's data model (its Working with databases guide): a database contains one or more data sources, and a data source is the parent of its pages. The query endpoint takes the data source id, which Retrieve a database lists in its data_sources array:
curl -s "https://api.notion.com/v1/databases/$DATABASE_ID" \
-H "Authorization: Bearer $NOTION_TOKEN" -H "Notion-Version: 2026-03-11" | python3 -c 'import json,sys; [print(d["id"], d["name"]) for d in json.load(sys.stdin)["data_sources"]]'
python3 papertype-notion.py --data-source 248104cd-477e-80af-bc30-000bd28de8f9 --outdir pdfs
python3 papertype-notion.py --data-source 248104cd-… --outdir pdfs --filter '{"property": "Done", "checkbox": {"equals": true}}'
# 2 page(s) in data source
# pdfs/Q4-launch-plan.pdf 3 page(s)
# pdfs/Second-row.pdf 1 page(s)The script posts {"page_size": 100, "filter": …, "start_cursor": …} to /v1/data_sources/{id}/query and follows next_cursor while has_more is true (Notion caps one query at 10,000 results). The --filter value is Notion's filter object exactly as its Filter data source entries reference shows it, so a checkbox, status or date condition selects the rows. Each resulting page is rendered as above, properties table included, into <title>.pdf. Where the database id comes from: the database's link in Notion, the same 32-character id as for a page (Notion documents the procedure for pages; for a database link the id precedes ?v=, which was not checked against a live workspace).
Live result: with the query stubbed to return two pages over two cursor pages, the script wrote two PDFs; a row with no body content gets a one-page PDF with its title and properties.
Limits that matter
- Notion rate limits (its request-limits page, 2026-10-23): 180 requests per minute per connection on free and Plus workspaces, 600 on Business and Enterprise, and about 3 data-source queries per second; over the limit Notion returns
429withRetry-After, which the script waits for and retries up to three times. Each page costs two Notion requests, so a 500-row database takes a few minutes on a free workspace. - Our quota. Free-plan output carries a small
papertype.devfooter mark. Paid plans (from $9/month) do not. The free plan is 50 documents a month and the API returns429at the limit; a 500-row export needs the $9 plan (500 documents) or the next one up. Failed renders are not counted. - 2 MB of source per request, 300 pages per document, 2 concurrent renders per account. A long Notion page is far below 2 MB as text; images are what fill it, hence the inlining budget above.
- Errors. Notion errors surface as
Notion <status>: <code> <message>(404 means the page does not exist or is not shared with the token; 401 a bad token; 403 a connection without read-content capability). Ours surface asPaperType <status>: <detail>; a bad key givesPaperType 401: Missing or invalid API key, as the test checked. - What is stored. With the default output the PDF is the response and nothing is kept on our side. With
--urlthe finished PDF (never the Markdown) is kept at a public, unguessable link for 24 hours, as the privacy policy describes; the Notion token is sent only toapi.notion.com. - Fonts. Installed fonts: DejaVu, Liberation, Noto, Carlito and Caladea. Embed any other font with an
@font-facerule whosesrcis adata:URI.
Questions
Did you test this against Notion?
Not against a workspace. The script file was executed on 2026-10-23 with its `notion()` function replaced by a stub returning responses in the shapes Notion's documentation shows (a page object with ten property types, a truncated markdown response with `unknown_block_ids`, a two-cursor data-source query) and the PaperType half real: both request bodies were posted to the live API, the PDFs saved and read back with pdftotext (title, property table, callout, appendix and contents page present), the `--url` link downloaded without a key, and the 401 path checked. The converter was tested on Notion's own documented syntax examples. If a Notion detail is wrong, tell support@papertype.dev and the page gets fixed.
Do I have to share pages with the integration?
Not with a personal access token: Notion's docs say a PAT uses the permissions of the person who created it and needs no "Add connections" step. With an internal connection, yes: share each page or database with the connection, and give it the read-content capability.
Why not Notion's built-in Export to PDF?
Use it for one page by hand. The script is for the cases it does not cover: many pages or a filtered database in one go, a PDF produced by a job rather than a person, a contents page and page numbers, your own theme or CSS, and Letter or A5 output.
Can it run on a schedule or from a webhook?
Yes; it is a plain Python script with two environment variables, so a cron entry, a GitHub Actions job (see the GitHub Actions guide) or any server can run it. Notion's API has no built-in "page changed" trigger in the parts fetched for this page, so poll the data source with a filter such as `last_edited_time` on or after your last run.
What about child pages, databases inside the page, and comments?
A child page appears in the markdown as a `<page url>` reference and prints as a link; render it separately. An inline database likewise appears as a `<database url>` link; export its rows with `--data-source`. Comments are not part of page content and are not included.
Related
Free previews carry a watermark and are deleted after 24 hours. Free API output carries a footer mark. The renderer runs no JavaScript and never fetches external URLs. Privacy · Terms · Refunds