Skip to main content
RapidDev - Software Development Agency
API AutomationsNotionBearer Token

How to Automate Notion Content Publishing using the API

Automate a Notion-to-CMS publishing pipeline: subscribe to page.updated webhooks, then recursively fetch all blocks with GET /v1/blocks/{id}/children (100 blocks per page, must recurse for nested content), transform block types to HTML or Markdown, and push to an external CMS API. Critical: image block URLs expire in ~1 hour — download and re-upload images immediately. A 30-block article with toggles can require 10+ paginated block-children requests. Rate limit: 3 req/s.

Need help automating? Talk to an expert
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced7 min read1-2 hoursNotionMay 2026RapidDev Engineering Team
TL;DR

Automate a Notion-to-CMS publishing pipeline: subscribe to page.updated webhooks, then recursively fetch all blocks with GET /v1/blocks/{id}/children (100 blocks per page, must recurse for nested content), transform block types to HTML or Markdown, and push to an external CMS API. Critical: image block URLs expire in ~1 hour — download and re-upload images immediately. A 30-block article with toggles can require 10+ paginated block-children requests. Rate limit: 3 req/s.

API Quick Reference

Auth

Bearer Token

Rate limit

3 requests/second

Format

JSON

SDK

Available

Understanding the Notion API

The Notion REST API uses a block-tree model for page content. Every piece of content in Notion is a block: paragraph, heading_1/2/3, code, image, quote, callout, toggle, bulleted_list_item, numbered_list_item, divider, bookmark, table, column_list, and more. GET /v1/blocks/{id}/children returns only one level deep with a maximum of 100 blocks per page — nested content (toggle children, column contents, table rows) requires recursive follow-up calls.

For content publishing, the automation must: detect when a page's status changes to 'Ready to Publish' via a webhook or poll, recursively walk the entire block tree, transform each block type to the target CMS format (HTML, Markdown, or JSON), handle image blocks specially (download before URL expiry), and finally push to the external CMS API.

This is the most technically demanding Notion automation because of block-tree traversal complexity. Official documentation: developers.notion.com.

Base URLhttps://api.notion.com

Setting Up Notion API Authentication

The content publishing pipeline needs Read content to fetch blocks, Update content to mark pages as 'Published', and webhook subscription access using Notion-Version: 2026-03-01. The webhook fires on page.updated events when a content author changes the status to 'Ready to Publish'.

  1. 1Go to notion.so/my-integrations (workspace owner required)
  2. 2Create or open your integration and enable: Read content, Update content
  3. 3Copy the Internal Integration Secret (ntn_ prefix)
  4. 4Share your content database with the integration via '...' > Connections
  5. 5Set up a public HTTPS endpoint for webhook delivery
  6. 6Create webhook subscription (POST /v1/webhooks with Notion-Version: 2026-03-01) for page.updated events
  7. 7Store credentials: NOTION_TOKEN, NOTION_WEBHOOK_SECRET, CONTENT_DB_DS_ID
auth.py
1import os
2import requests
3
4NOTION_TOKEN = os.environ['NOTION_TOKEN']
5
6HEADERS = {
7 'Authorization': f'Bearer {NOTION_TOKEN}',
8 'Notion-Version': '2025-09-03',
9 'Content-Type': 'application/json'
10}
11
12WEBHOOK_HEADERS = {
13 'Authorization': f'Bearer {NOTION_TOKEN}',
14 'Notion-Version': '2026-03-01', # required for webhook endpoints
15 'Content-Type': 'application/json'
16}

Security notes

  • Store NOTION_TOKEN and NOTION_WEBHOOK_SECRET in environment variables
  • Image URLs expire in ~1 hour — download and store images immediately, never cache Notion file URLs
  • Verify X-Notion-Signature on every incoming webhook before processing
  • The webhook verification_token is your signing secret — treat it like a password
  • Never log raw page content that may contain sensitive draft material

Key endpoints

GET/v1/blocks/{block_id}/children

Fetch child blocks of a page or block. Returns max 100 blocks per page with cursor-based pagination. Must be called recursively for nested blocks (toggles, column_lists, table_rows). This is the core endpoint for extracting article content.

ParameterTypeRequiredDescription
block_idstringrequiredThe page ID or block ID whose children to fetch. Use the page ID to start the recursive walk.
page_sizenumberoptionalMax 100 blocks per request.
start_cursorstringoptionalCursor from previous response for pagination within the same block's children.

Response

json
1{"object":"list","results":[{"id":"block-id-1","type":"heading_2","heading_2":{"rich_text":[{"plain_text":"Introduction"}]}},{"id":"block-id-2","type":"paragraph","paragraph":{"rich_text":[{"plain_text":"Welcome to this guide..."}]}},{"id":"block-id-3","type":"toggle","has_children":true,"toggle":{"rich_text":[{"plain_text":"Show more details"}]}}],"next_cursor":null,"has_more":false}
GET/v1/pages/{page_id}

Fetch a page's properties — used to check the status property value and get article metadata (title, author, publish date, featured image relation).

ParameterTypeRequiredDescription
page_idstringrequiredUUID of the Notion page to retrieve.

Response

json
1{"object":"page","id":"page-id","properties":{"Title":{"title":[{"plain_text":"How to Deploy on Vercel"}]},"Status":{"select":{"name":"Ready to Publish"}},"Author":{"people":[{"name":"Alice"}]},"Publish Date":{"date":{"start":"2026-05-07"}}}}
POST/v1/webhooks

Subscribe to page.updated events for the content database. Requires Notion-Version: 2026-03-01. Use this to get notified when a page status changes to 'Ready to Publish' without polling.

ParameterTypeRequiredDescription
urlstringrequiredHTTPS endpoint for webhook delivery.
eventsarrayrequired['page.updated'] for status change detection.
resourceobjectrequired{type: 'database', id: 'DB_ID'}.

Request

json
1{"url":"https://yourapp.com/webhooks/notion","events":["page.updated"],"resource":{"type":"database","id":"CONTENT_DB_ID"}}

Response

json
1{"id":"webhook-id","verification_token":"verify_token","active":false}
PATCH/v1/pages/{page_id}

Update the page status to 'Published' after successful CMS push, creating a clear audit trail in Notion.

ParameterTypeRequiredDescription
propertiesobjectoptionalProperties to update. Only include properties you want to change.

Request

json
1{"properties":{"Status":{"select":{"name":"Published"}}}}

Response

json
1{"object":"page","id":"page-id","properties":{"Status":{"select":{"name":"Published"}}}}

Step-by-step automation

1

Detect Status Change via Webhook

Why: Real-time webhook detection means articles are published within seconds of being marked ready, rather than waiting for a polling interval.

Configure a POST /v1/webhooks subscription for page.updated events on your content database. In the handler, verify the X-Notion-Signature, fetch the full page with GET /v1/pages/{id}, and check the Status property value. Only proceed if the status is exactly 'Ready to Publish'.

request.sh
1# Create webhook subscription
2curl -X POST 'https://api.notion.com/v1/webhooks' \
3 -H 'Authorization: Bearer $NOTION_TOKEN' \
4 -H 'Notion-Version: 2026-03-01' \
5 -H 'Content-Type: application/json' \
6 -d '{"url":"https://yourapp.com/webhooks/notion","events":["page.updated"],"resource":{"type":"database","id":"CONTENT_DB_ID"}}'

Pro tip: Webhook payloads do not tell you which property changed — they only say the page was updated. You must fetch the page and check the status on every page.updated event, even if the status did not change. Cache the last known status per page ID to avoid re-publishing if the webhook fires multiple times.

Expected result: The webhook handler returns 200 immediately. When a page status changes to 'Ready to Publish', the publishing pipeline starts asynchronously.

2

Recursively Fetch All Blocks

Why: GET /v1/blocks/{id}/children only returns one level deep — nested content inside toggles, column lists, and tables requires recursive calls.

Start with the page ID, fetch its children with pagination, then recursively fetch children for any block where has_children is true. Block types requiring recursion: toggle, column_list (then each column), table (then each table_row), synced_block, template, and some callout blocks. Images and other leaf blocks have has_children: false and do not need recursion.

request.sh
1# Fetch top-level page blocks
2curl -X GET 'https://api.notion.com/v1/blocks/PAGE_ID/children?page_size=100' \
3 -H 'Authorization: Bearer $NOTION_TOKEN' \
4 -H 'Notion-Version: 2025-09-03'
5
6# Fetch nested children of a toggle block
7curl -X GET 'https://api.notion.com/v1/blocks/TOGGLE_BLOCK_ID/children?page_size=100' \
8 -H 'Authorization: Bearer $NOTION_TOKEN' \
9 -H 'Notion-Version: 2025-09-03'

Pro tip: Skip recursion for 'child_page' and 'child_database' blocks — these are references to other Notion pages and databases, not inline content. Including them would cause infinite recursion if the article has database embeds.

Expected result: A flat array of all blocks in the article, including nested content with _depth metadata. Each block has its type and the type-specific content object.

3

Download Images Before URL Expiry

Why: Notion image URLs expire in approximately 1 hour — if you wait until the HTML transformation step to download them, they may have already expired.

Scan the block list for image blocks immediately after fetching. Download each image URL to your server or S3 bucket and replace the Notion URL with your permanent URL before proceeding to transformation. Image blocks contain a file.url or external.url field depending on whether the image was uploaded to Notion or linked from an external source.

request.sh
1# Download a Notion image (the URL is time-limited)
2curl -L 'NOTION_IMAGE_URL_HERE' -o 'article-image-1.jpg'
3
4# Then upload to your CDN/S3 and use the permanent URL in your HTML

Pro tip: In production, upload images to S3 or Cloudflare R2 immediately after download and store the permanent public URL. Your CMS will need a permanent URL, not a local file path.

Expected result: All image blocks have a _local_image_path or permanent CDN URL attached. Image downloads happen before Notion URLs expire.

4

Transform Blocks to HTML and Push to CMS

Why: Each Notion block type maps to a specific HTML element — the transformer converts the Notion block tree into the markup your CMS expects.

Iterate through the flat block list and convert each block type to HTML or Markdown. Paragraph → p, heading_2 → h2, code → pre/code, image → img, bulleted_list_item → li, etc. Rich text within blocks can have annotations (bold, italic, code, link, color) that map to HTML inline elements. Finally, POST the transformed content to your CMS API (WordPress, Webflow, or a headless CMS).

request.sh
1# Push transformed HTML to WordPress
2curl -X POST 'https://yoursite.com/wp-json/wp/v2/posts' \
3 -H 'Authorization: Basic BASE64_USER_APPPASS' \
4 -H 'Content-Type: application/json' \
5 -d '{"title":"Article Title","content":"<p>Your HTML content here</p>","status":"publish"}'

Pro tip: Notion's rich text can contain color annotations and Notion-specific mention types. HTML has no equivalent for Notion colors — strip them or map to CSS classes. Mention blocks (@user, @page, @date) appear as plain text in plain_text — decide whether to render them as links or strip them.

Expected result: A valid HTML string containing the article content. Use this as the 'content' field when pushing to WordPress, Webflow, or any headless CMS API.

Complete working code

This complete script is a Flask webhook handler that receives Notion page.updated events, detects 'Ready to Publish' status, recursively fetches all blocks, downloads images, transforms to HTML, pushes to WordPress via REST API, and marks the Notion page as 'Published'.

automate_notion_publish.py
1import os
2import hmac
3import hashlib
4import time
5import threading
6import requests
7from flask import Flask, request, jsonify
8
9app = Flask(__name__)
10
11NOTION_TOKEN = os.environ['NOTION_TOKEN']
12WEBHOOK_SECRET = os.environ['NOTION_WEBHOOK_SECRET']
13WP_BASE = os.environ['WP_BASE_URL'] # e.g. https://yoursite.com/wp-json/wp/v2
14WP_AUTH = (os.environ['WP_USERNAME'], os.environ['WP_APP_PASSWORD'])
15
16H = {'Authorization': f'Bearer {NOTION_TOKEN}', 'Notion-Version': '2025-09-03'}
17
18def verify_sig(body, sig):
19 d = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
20 return hmac.compare_digest(f'sha256={d}', sig)
21
22def notion_get(path):
23 for _ in range(5):
24 r = requests.get(f'https://api.notion.com{path}', headers=H)
25 if r.status_code == 429:
26 time.sleep(int(r.headers.get('Retry-After', 5)))
27 continue
28 r.raise_for_status()
29 time.sleep(0.35)
30 return r.json()
31 raise Exception(f'Max retries for {path}')
32
33def fetch_children(block_id):
34 blocks, cursor = [], None
35 while True:
36 params = {'page_size': 100}
37 if cursor: params['start_cursor'] = cursor
38 r = requests.get(f'https://api.notion.com/v1/blocks/{block_id}/children', headers=H, params=params)
39 if r.status_code == 429:
40 time.sleep(int(r.headers.get('Retry-After', 5)))
41 continue
42 r.raise_for_status()
43 data = r.json()
44 blocks.extend(data['results'])
45 if not data['has_more']: break
46 cursor = data['next_cursor']
47 time.sleep(0.35)
48 return blocks
49
50def fetch_all(block_id, depth=0):
51 if depth > 5: return []
52 result = []
53 for block in fetch_children(block_id):
54 block['_depth'] = depth
55 result.append(block)
56 if block.get('has_children') and block['type'] not in ['child_page', 'child_database']:
57 result.extend(fetch_all(block['id'], depth + 1))
58 return result
59
60def rich_to_html(arr):
61 out = ''
62 for rt in arr:
63 txt = rt.get('plain_text', '')
64 ann = rt.get('annotations', {})
65 if ann.get('bold'): txt = f'<strong>{txt}</strong>'
66 if ann.get('italic'): txt = f'<em>{txt}</em>'
67 if ann.get('code'): txt = f'<code>{txt}</code>'
68 if rt.get('href'): txt = f'<a href="{rt["href"]}">{txt}</a>'
69 out += txt
70 return out
71
72def to_html(blocks):
73 parts = []
74 for b in blocks:
75 t = b['type']
76 d = b.get(t, {})
77 rt = d.get('rich_text', [])
78 if t == 'paragraph': parts.append(f'<p>{rich_to_html(rt)}</p>')
79 elif t in ('heading_1','heading_2','heading_3'):
80 n = t[-1]
81 parts.append(f'<h{n}>{rich_to_html(rt)}</h{n}>')
82 elif t in ('bulleted_list_item','numbered_list_item'):
83 parts.append(f'<li>{rich_to_html(rt)}</li>')
84 elif t == 'code':
85 parts.append(f'<pre><code>{rich_to_html(rt)}</code></pre>')
86 elif t == 'image':
87 src = b.get('_img_url', '#')
88 parts.append(f'<img src="{src}" alt="">')
89 elif t == 'quote':
90 parts.append(f'<blockquote>{rich_to_html(rt)}</blockquote>')
91 elif t == 'divider': parts.append('<hr>')
92 return '\n'.join(parts)
93
94def publish_page(page):
95 page_id = page['id']
96 title = page['properties'].get('Title', {}).get('title', [])
97 title_text = title[0]['plain_text'] if title else 'Untitled'
98 blocks = fetch_all(page_id)
99 # Download images
100 for b in blocks:
101 if b['type'] == 'image':
102 img = b['image']
103 url = img['file']['url'] if img['type'] == 'file' else img['external']['url']
104 try:
105 r = requests.get(url, timeout=30)
106 b['_img_url'] = url # In prod: upload to S3, use permanent URL
107 except: b['_img_url'] = '#'
108 html = to_html(blocks)
109 # Push to WordPress
110 wp_resp = requests.post(f'{WP_BASE}/posts', auth=WP_AUTH, json={
111 'title': title_text, 'content': html, 'status': 'publish'
112 })
113 wp_resp.raise_for_status()
114 print(f'Published to WordPress: {wp_resp.json()["link"]}')
115 # Mark Notion page as Published
116 requests.patch(f'https://api.notion.com/v1/pages/{page_id}', headers={**H, 'Content-Type': 'application/json'},
117 json={'properties': {'Status': {'select': {'name': 'Published'}}}})
118
119@app.route('/webhooks/notion', methods=['POST'])
120def webhook():
121 raw = request.get_data()
122 if not verify_sig(raw, request.headers.get('X-Notion-Signature', '')):
123 return jsonify({'error': 'bad sig'}), 401
124 p = request.json
125 page_id = p.get('entity', {}).get('id')
126 if page_id:
127 page = notion_get(f'/v1/pages/{page_id}')
128 if page.get('properties', {}).get('Status', {}).get('select', {}).get('name') == 'Ready to Publish':
129 threading.Thread(target=publish_page, args=(page,), daemon=True).start()
130 return jsonify({'ok': True})
131
132if __name__ == '__main__':
133 app.run(port=3000)

Error handling

400{"object":"error","status":400,"code":"missing_version","message":"Notion-Version header is required."}
Cause

The Notion-Version header is missing. The webhook subscription endpoint requires version 2026-03-01; all other endpoints use 2025-09-03.

Fix

Maintain two header objects: one with Notion-Version: 2025-09-03 for page/block calls and one with 2026-03-01 for webhook management.

Retry strategy

No retry — fix the header.

403Access Denied (from AWS S3 when accessing expired Notion image URL)
Cause

Notion image file URLs expire after approximately 1 hour. If you try to download or render the URL after expiry, S3 returns 403.

Fix

Download images immediately after fetching blocks, before any transformation or waiting. In the webhook handler, image download must be the first operation after block fetching.

Retry strategy

Re-fetch the page and blocks to get fresh URLs if images have expired. Set a 45-minute maximum between block fetching and image processing.

404{"object":"error","status":404,"code":"object_not_found","message":"Could not find block with ID: ..."}
Cause

A block that has_children was deleted between the parent fetch and the children fetch, or the page was not shared with the integration.

Fix

Handle 404 gracefully during recursive block fetching — skip blocks that return 404 and continue with siblings. Log the block ID for debugging.

Retry strategy

Do not retry 404 — skip and continue.

429{"object":"error","status":429,"code":"rate_limited"}
Cause

A 30-block article with 3 nested toggles requires at least 4 paginated API calls just to fetch blocks, plus additional calls for images and metadata.

Fix

Add 350ms delays between every block-children call and every recursive fetch. Honor Retry-After header if throttled.

Retry strategy

Sleep for Retry-After seconds, then retry the specific failed call.

500WordPress REST API error: Sorry, you are not allowed to publish posts.
Cause

The WordPress Application Password belongs to an Author-level user who does not have publish_posts capability. Only Editor and Administrator roles can publish directly.

Fix

Use a WordPress Application Password belonging to an Editor or Administrator account. Authors can only create drafts (status: 'draft').

Retry strategy

No retry — fix the WordPress user role.

Rate Limits for Notion API

ScopeLimitWindow
Per integration token3 requests averageper second
API calls per article publish10-20+ requestsper article (block pages + recursive children + metadata + status update)
Block children per request100 blocksper request
retry-handler.ts
1import time
2import requests
3
4def notion_get_with_retry(url, headers, params=None, max_retries=5):
5 for attempt in range(max_retries):
6 resp = requests.get(url, headers=headers, params=params)
7 if resp.status_code == 429:
8 wait = int(resp.headers.get('Retry-After', 2 ** attempt))
9 print(f'Rate limited. Waiting {wait}s...')
10 time.sleep(wait)
11 continue
12 resp.raise_for_status()
13 time.sleep(0.35) # proactive buffer
14 return resp.json()
15 raise Exception(f'Max retries for {url}')
  • Add 350ms delays between every block-children call and every recursive child fetch
  • Depth-limit the recursion to 5 levels — articles with deeper nesting are extremely rare and unlimited recursion is a denial-of-service risk
  • Download images in the block loop, not a separate subsequent loop — minimizes time between URL issuance and download
  • Process only one article at a time — concurrent publishing pipelines multiply the API calls and hit the rate limit fast
  • Cache block metadata (has_children flag) to skip unnecessary recursive calls for leaf blocks

Security checklist

  • Verify X-Notion-Signature on every webhook using HMAC-SHA256 before processing any content
  • Download Notion image URLs immediately — they expire and become inaccessible after ~1 hour
  • Store image files with non-guessable names (use the Notion block UUID as the filename) to prevent enumeration
  • WordPress Application Passwords should belong to a dedicated automation account (Editor role), not your personal admin account
  • Store NOTION_TOKEN, NOTION_WEBHOOK_SECRET, and WP_APP_PASSWORD in environment variables only
  • Sanitize HTML output before sending to the CMS to prevent XSS from rich text link href values
  • Never log full page content in your webhook handler — it may contain unpublished drafts

Automation use cases

Notion-to-WordPress Publisher

advanced

Detect 'Ready to Publish' status in Notion, extract all blocks, transform to HTML, upload to WordPress with featured image, and mark the Notion page as Published.

Notion-to-Markdown Export

intermediate

Transform Notion blocks to Markdown format and commit to a GitHub repository, enabling static site generators (Jekyll, Hugo, Astro) to pick up the new content.

Multi-CMS Syndication

advanced

After publishing to WordPress, also push to a Webflow CMS collection and a headless CMS (Contentful, Sanity), with per-destination field mapping for each CMS's schema.

No-code alternatives

Don't want to write code? These platforms can automate the same workflows visually.

Zapier

Free tier available; paid plans from $19.99/month

Zapier can detect Notion database row updates and create WordPress posts, though it cannot perform recursive block fetching or image downloading natively.

Pros
  • + No code required for simple text content
  • + WordPress integration included
  • + Handles scheduling and retries
Cons
  • - Cannot recursively fetch nested Notion blocks
  • - No image download/re-upload capability
  • - Rich text formatting is lost

Make

Free tier available; paid plans from $9/month

Make's Notion and WordPress modules support basic content transfers, with iterators for processing multiple blocks, though recursive traversal requires custom workarounds.

Pros
  • + More powerful than Zapier for multi-step flows
  • + Iterator module processes block arrays
  • + Lower cost at scale
Cons
  • - Cannot recurse into nested blocks natively
  • - Image expiry not handled automatically
  • - Complex mappings require manual JSON setup

n8n

Free self-hosted; cloud from €20/month

n8n's Code node can implement the full recursive block fetching and transformation logic, making it the most capable no-code alternative for this advanced automation.

Pros
  • + Code node handles arbitrary recursion
  • + Self-hostable
  • + Webhook node for real-time triggers
Cons
  • - Requires JavaScript knowledge in the Code node
  • - Not truly no-code for complex block traversal
  • - Notion API version support may lag

Best practices

  • Download image URLs immediately after fetching blocks — they expire in ~1 hour and you cannot re-fetch them without another API call
  • Depth-limit recursive block fetching to 5 levels as a safety guard against pathological nesting
  • Never re-process a 'Ready to Publish' page that has already been published — check for a 'Published' status before starting the pipeline
  • Store the WordPress post URL back in the Notion page after publishing using PATCH /v1/pages/{id} with a URL property
  • Handle unknown block types gracefully — skip them and log the type so you can add support later
  • Return 200 from the webhook handler immediately and process the full pipeline asynchronously
  • Use the Notion block ID as the image filename to create a deterministic mapping between Notion content and CMS media

Ask AI to help

Copy one of these prompts to get a personalized, working implementation.

ChatGPT / Claude Prompt

I'm building a Notion-to-WordPress content pipeline using the Notion API v2025-09-03. My recursive block fetching function calls GET /v1/blocks/{id}/children and recurses on blocks where has_children is true. The function works for flat articles but enters an infinite loop on some pages. Here is my recursion code: {paste code here}. The problematic page has child_database blocks. How do I prevent infinite recursion and which block types should I skip?

Lovable / V0 Prompt

Build a Next.js content publishing dashboard that connects to Notion and WordPress. The app should show all Notion database pages with a 'Ready to Publish' status in a table (fetched via POST /v1/data_sources/{id}/query). Each row should have a 'Publish Now' button that: fetches the full page metadata with GET /v1/pages/{id}, recursively fetches all blocks via GET /v1/blocks/{id}/children, transforms blocks to HTML (paragraph to p, heading_2 to h2, code to pre/code, image to img), POSTs to WordPress /wp-json/wp/v2/posts using Application Password auth, and updates the Notion page status to 'Published' via PATCH /v1/pages/{id}. Show a progress indicator during publishing and display the WordPress post URL when done. Use NOTION_TOKEN and WordPress credentials from environment variables.

Frequently asked questions

Why do I get 403 errors when accessing Notion image URLs?

Notion stores images on AWS S3 with time-limited signed URLs that expire after approximately 1 hour. After expiry, S3 returns 403 Access Denied. The solution is to download images immediately after fetching the blocks that contain them — never wait, never cache the URL. In production, upload the downloaded image to your own CDN or S3 bucket and use the permanent URL in your CMS.

How many API calls does it take to publish a typical blog post?

A 20-block article without nesting requires approximately 4 calls: 1 webhook trigger follow-up (GET /v1/pages/{id}), 1-2 block children pages (GET /v1/blocks/{id}/children), and 1 status update (PATCH /v1/pages/{id}). A 50-block article with 5 nested toggles and a table requires 8-12 calls just for block fetching. Each image download is an additional external HTTP call (not rate-limited by Notion).

What block types require recursive fetching?

Any block where has_children is true requires a follow-up GET /v1/blocks/{id}/children call. Common types: toggle, column_list (then each column block), table (then each table_row), callout (sometimes), synced_block. Leaf blocks (paragraph, heading, image, code, divider) never have children. Skip recursion for child_page and child_database to avoid infinite loops.

Can I convert Notion blocks to Markdown instead of HTML?

Yes. Instead of wrapping content in HTML tags, use Markdown equivalents: # for heading_1, ## for heading_2, > for quote, ** ** for bold, * * for italic, ` ` for inline code, ```lang for code blocks, - for bulleted_list_item, 1. for numbered_list_item. The same block iteration and rich text extraction logic applies — just change the output format.

Is the Notion API free for content pipeline automation?

Yes. The Notion API is free with no paid tier. The only limitation is the 3 req/s rate limit, which is the same for all plan levels. The webhook system (used for real-time detection) is in public beta but free to use.

How do I handle Notion synced blocks in the pipeline?

Synced blocks (type: synced_block) have has_children: true and contain a synced_from object pointing to the original block ID. Fetch the children of the synced_block's own ID — Notion automatically returns the synced content. Do not follow the synced_from reference directly, as that points to the source block which may be in a different page.

What is the difference between Notion-Version: 2025-09-03 and 2026-03-11?

In version 2026-03-11, two things changed: 'archived' property on pages was renamed to 'in_trash', and the 'after' string parameter for PATCH /v1/blocks/{id}/children was replaced by a 'position' object. For read-only operations like block fetching and page reading, both versions work identically. Pin to 2025-09-03 for stability unless you need position-based block insertion.

Can RapidDev build a custom Notion content publishing pipeline?

Yes. RapidDev has built 600+ apps including automated content pipelines from Notion to WordPress, Webflow, and headless CMSes. We handle recursive block traversal, image CDN migration, and CMS field mapping. Visit rapidevelopers.com for a free consultation.

RapidDev

Need this automated?

Our team has built 600+ apps with API automations. We can build this for you.

Book a free consultation

Skip the coding — we'll build it for you

Our experts have built 600+ API automations. From prototype to production in days, not weeks.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.