Automate weekly Notion database reports by querying rows with POST /v1/data_sources/{id}/query (cursor-paginated, 100 rows max per page), aggregating numeric properties in code, and writing the formatted summary back as a new Notion page via POST /v1/pages with PATCH /v1/blocks/{id}/children to append rich-text blocks. The critical constraint is 3 requests/second — a 500-row database with a 20-block report can chain 15+ requests.
API Quick Reference
Bearer Token
3 requests/second
JSON
Available
Understanding the Notion API
The Notion REST API enables programmatic access to databases, pages, and blocks. As of API version 2025-09-03, databases are containers holding one or more data sources, and row queries use POST /v1/data_sources/{id}/query instead of the legacy /v1/databases/{id}/query. All requests require the Notion-Version header.
Database reports involve three sequential operations: querying all rows with pagination, aggregating values in your script, and writing the report back to Notion as a new page with structured blocks. Rich text in Notion is never a plain string — it is always an array of rich text objects with type, text.content, and annotation fields.
Rollup properties require separate pagination and cannot be exhausted in a single response — factor this in for databases using rollups for calculated totals. Official documentation is at developers.notion.com.
https://api.notion.comSetting Up Notion API Authentication
Notion uses long-lived Bearer tokens for internal integrations. For report automation you need both Read content (to query rows) and Insert content (to create the report page and append blocks) capabilities. Without Insert content, POST /v1/pages will return 403 restricted_resource.
- 1Go to notion.so/my-integrations (workspace owner required)
- 2Click 'New integration', give it a name like 'Report Automation'
- 3Enable capabilities: Read content, Insert content, Update content
- 4Save and copy the Internal Integration Secret (ntn_ prefix)
- 5Open your source database in Notion, click '...' > Connections > connect your integration
- 6Open the parent page where reports will be created and also connect the integration there
- 7Set the environment variable: export NOTION_TOKEN=ntn_your_token
1import os2import requests34NOTION_TOKEN = os.environ['NOTION_TOKEN']56HEADERS = {7 'Authorization': f'Bearer {NOTION_TOKEN}',8 'Notion-Version': '2025-09-03',9 'Content-Type': 'application/json'10}1112# Verify connectivity13resp = requests.get('https://api.notion.com/v1/users/me', headers=HEADERS)14print('Connected as:', resp.json().get('name', 'unknown'))Security notes
- •Store the Notion token in environment variables, never in source code
- •Enable only the capabilities the automation needs — Read and Insert suffice for reporting
- •Only share the specific databases and parent pages with the integration
- •Rotate the token immediately via notion.so/my-integrations if it is ever exposed
- •Never log the full Authorization header value
Key endpoints
/v1/data_sources/{data_source_id}/queryQuery rows from a Notion data source with filter, sort, and cursor-based pagination. Returns a maximum of 100 results per page — iterate using has_more and next_cursor for large databases.
| Parameter | Type | Required | Description |
|---|---|---|---|
filter | object | optional | Property filter conditions. Date filters support past_week, past_month, this_week, before, after, equals. |
sorts | array | optional | Sort by property name and direction (ascending/descending). |
page_size | number | optional | Max 100 results per page. |
start_cursor | string | optional | Cursor from previous response for pagination. |
Request
1{"filter":{"property":"Date","date":{"past_week":{}}},"sorts":[{"property":"Date","direction":"descending"}],"page_size":100}Response
1{"object":"list","results":[{"id":"page-id","properties":{"Revenue":{"number":1250.00},"Date":{"date":{"start":"2026-05-01"}},"Client":{"title":[{"plain_text":"Acme Corp"}]}}}],"next_cursor":"cursor-value","has_more":true}/v1/pagesCreate a new Notion page. For database report pages, set parent.database_id to add it as a row, or parent.page_id to create it as a sub-page inside a report archive page.
| Parameter | Type | Required | Description |
|---|---|---|---|
parent | object | required | Either {page_id: 'ID'} for a sub-page or {database_id: 'ID'} for a database row. |
properties | object | required | Page properties. For plain sub-pages, only the title property is required. |
children | array | optional | Initial block children to add to the page. Limited to 100 blocks in the creation request. |
Request
1{"parent":{"page_id":"PARENT_PAGE_ID"},"properties":{"title":{"title":[{"type":"text","text":{"content":"Weekly Report: May 2026"}}]}}}Response
1{"object":"page","id":"new-page-id","url":"https://notion.so/workspace/new-page-id","properties":{"title":{"title":[{"plain_text":"Weekly Report: May 2026"}]}}}/v1/blocks/{block_id}/childrenAppend block children to a page or block. Use this to add the report body (headings, paragraphs, tables) after creating the page. Limited to 100 blocks per request.
| Parameter | Type | Required | Description |
|---|---|---|---|
children | array | required | Array of block objects to append. Max 100 per request. Each block must have object, type, and the type-specific property object. |
after | string | optional | Block ID after which to insert children (API version 2025-09-03). In API version 2026-03-11 this is replaced by a 'position' object. |
Request
1{"children":[{"object":"block","type":"heading_2","heading_2":{"rich_text":[{"type":"text","text":{"content":"Weekly Summary"}}]}},{"object":"block","type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Total revenue: $5,250"}}]}}]}Response
1{"object":"list","results":[{"object":"block","id":"block-id","type":"heading_2"},{"object":"block","id":"block-id-2","type":"paragraph"}]}Step-by-step automation
Query All Database Rows with Pagination
Why: Reports require complete data — if you stop at the first 100 rows, your totals will be wrong for databases with more than 100 entries.
Send POST /v1/data_sources/{id}/query with a date filter for the report period (e.g., past week or past month). Check has_more in the response — if true, use next_cursor as the start_cursor in the next request. Continue until has_more is false. Remember that rollup values may require additional paginated calls.
1curl -X POST 'https://api.notion.com/v1/data_sources/YOUR_DATA_SOURCE_ID/query' \2 -H 'Authorization: Bearer $NOTION_TOKEN' \3 -H 'Notion-Version: 2025-09-03' \4 -H 'Content-Type: application/json' \5 -d '{6 "filter": {"property": "Date", "date": {"past_week": {}}},7 "page_size": 1008 }'Pro tip: For large databases (1,000+ rows), consider filtering by date range on the server side rather than fetching all rows. A date filter like {property: 'Date', date: {on_or_after: '2026-05-01'}} reduces the number of rows returned and speeds up the query.
Expected result: A flat array of all page objects for the report period. Each object contains a properties key with all the database properties for that row.
Aggregate Numeric Properties
Why: Notion's API does not compute aggregations for you — you must sum, average, and count in your script using the values from each row.
Loop through the fetched rows and extract numeric property values. Handle null values (properties with no value return null in Notion). Build a summary object with totals, averages, and counts that you will write to the report page.
1# Aggregation is done in code, not via a Notion API endpoint.2# This step is pure computation — no API calls needed.Pro tip: Rich text property values (type: rich_text) are arrays of objects, not strings. Access them as props.Description.rich_text[0]?.plain_text. Always use optional chaining or null checks — any property can be empty.
Expected result: A summary object containing totals and averages you can reference when writing the report page blocks.
Create the Report Page
Why: Creating a dedicated page per report keeps your Notion workspace organized and creates a searchable archive of historical reports.
Send POST /v1/pages with parent.page_id set to your reports archive page. Set the title property to the report name including the date period. The page is created empty — you will append the content blocks in the next step.
1curl -X POST 'https://api.notion.com/v1/pages' \2 -H 'Authorization: Bearer $NOTION_TOKEN' \3 -H 'Notion-Version: 2025-09-03' \4 -H 'Content-Type: application/json' \5 -d '{6 "parent": {"page_id": "PARENT_PAGE_ID"},7 "properties": {8 "title": {9 "title": [{"type": "text", "text": {"content": "Weekly Report: May 1-7, 2026"}}]10 }11 }12 }'Pro tip: Rich text content strings have a 2,000 character limit per object. For long text content, split into multiple rich text objects in the array rather than one long string.
Expected result: A new page object with an id field. Store this page ID — you will use it as the block_id for appending content blocks in the next step.
Append Report Content Blocks
Why: Notion pages are composed of blocks — the report content (headings, stats, tables) must be appended via PATCH /v1/blocks/{page_id}/children rather than included as a string.
Build an array of block objects representing the report structure: a heading_2 for each section, paragraph blocks for stats, and optionally a table block for the row-by-row breakdown. Send them in batches of up to 100 via PATCH /v1/blocks/{report_page_id}/children. Rich text is always an array of objects.
1curl -X PATCH 'https://api.notion.com/v1/blocks/REPORT_PAGE_ID/children' \2 -H 'Authorization: Bearer $NOTION_TOKEN' \3 -H 'Notion-Version: 2025-09-03' \4 -H 'Content-Type: application/json' \5 -d '{6 "children": [7 {"object":"block","type":"heading_2","heading_2":{"rich_text":[{"type":"text","text":{"content":"Weekly Summary"}}]}},8 {"object":"block","type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Total Revenue: $5,250.00"}}]}},9 {"object":"block","type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Total Hours: 42.5"}}]}},10 {"object":"block","type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Entries: 12"}}]}}11 ]12 }'Pro tip: PATCH /v1/blocks/{id}/children appends to the end of the page. If you need to insert blocks at a specific position, use the 'after' parameter with the block ID to insert after (API v2025-09-03). In API v2026-03-11, this changes to a 'position' object — check your pinned version.
Expected result: The report page in Notion is populated with formatted headings, statistics, and a bullet list of top entries. The page is immediately visible at the URL from the POST /v1/pages response.
Complete working code
This complete script runs weekly: it queries a Notion database for the past week's entries, calculates revenue and hour totals, creates a new report page in a reports archive, and appends formatted summary blocks. It handles pagination for large databases and respects the 3 req/s rate limit.
1import os2import time3import requests4from datetime import date, timedelta56NOTION_TOKEN = os.environ['NOTION_TOKEN']7DATA_SOURCE_ID = os.environ['NOTION_DATA_SOURCE_ID']8PARENT_PAGE_ID = os.environ['NOTION_REPORTS_PAGE_ID']910HEADERS = {11 'Authorization': f'Bearer {NOTION_TOKEN}',12 'Notion-Version': '2025-09-03',13 'Content-Type': 'application/json'14}1516def query_all_rows():17 rows, cursor = [], None18 while True:19 payload = {'filter': {'property': 'Date', 'date': {'past_week': {}}}, 'page_size': 100}20 if cursor:21 payload['start_cursor'] = cursor22 resp = requests.post(23 f'https://api.notion.com/v1/data_sources/{DATA_SOURCE_ID}/query',24 headers=HEADERS, json=payload25 )26 resp.raise_for_status()27 data = resp.json()28 rows.extend(data['results'])29 if not data['has_more']:30 break31 cursor = data['next_cursor']32 time.sleep(0.35)33 return rows3435def aggregate(rows):36 s = {'revenue': 0.0, 'hours': 0.0, 'count': 0, 'items': []}37 for row in rows:38 p = row['properties']39 rev = p.get('Revenue', {}).get('number') or 040 hrs = p.get('Hours', {}).get('number') or 041 name = (p.get('Name', {}).get('title') or [{'plain_text': 'Untitled'}])[0]['plain_text']42 s['revenue'] += rev43 s['hours'] += hrs44 s['count'] += 145 s['items'].append({'name': name, 'revenue': rev, 'hours': hrs})46 s['avg'] = s['revenue'] / s['count'] if s['count'] else 047 return s4849def create_report_page(title):50 resp = requests.post('https://api.notion.com/v1/pages', headers=HEADERS, json={51 'parent': {'page_id': PARENT_PAGE_ID},52 'properties': {'title': {'title': [{'type': 'text', 'text': {'content': title}}]}}53 })54 resp.raise_for_status()55 return resp.json()['id']5657def append_blocks(page_id, blocks):58 for i in range(0, len(blocks), 100):59 resp = requests.patch(f'https://api.notion.com/v1/blocks/{page_id}/children',60 headers=HEADERS, json={'children': blocks[i:i+100]})61 resp.raise_for_status()62 time.sleep(0.35)6364def txt(content): return [{'type': 'text', 'text': {'content': content}}]6566def main():67 today = date.today()68 w_start = today - timedelta(days=today.weekday() + 7)69 w_end = w_start + timedelta(days=6)70 title = f'Weekly Report: {w_start.strftime("%b %d")} - {w_end.strftime("%b %d, %Y")}'71 print('Querying rows...')72 rows = query_all_rows()73 s = aggregate(rows)74 print(f"Revenue: ${s['revenue']:,.2f}, Hours: {s['hours']}, Count: {s['count']}")75 page_id = create_report_page(title)76 blocks = [77 {'object':'block','type':'heading_2','heading_2':{'rich_text':txt('Summary')}},78 {'object':'block','type':'paragraph','paragraph':{'rich_text':txt(f"Revenue: ${s['revenue']:,.2f}")}},79 {'object':'block','type':'paragraph','paragraph':{'rich_text':txt(f"Hours: {s['hours']:.1f}")}},80 {'object':'block','type':'paragraph','paragraph':{'rich_text':txt(f"Entries: {s['count']}")}},81 {'object':'block','type':'paragraph','paragraph':{'rich_text':txt(f"Avg per entry: ${s['avg']:,.2f}")}},82 {'object':'block','type':'heading_2','heading_2':{'rich_text':txt('Top 10 Entries')}},83 ]84 for item in sorted(s['items'], key=lambda x: x['revenue'], reverse=True)[:10]:85 blocks.append({'object':'block','type':'bulleted_list_item','bulleted_list_item':{86 'rich_text': txt(f"{item['name']}: ${item['revenue']:,.2f} ({item['hours']}h)")87 }})88 append_blocks(page_id, blocks)89 print(f'Report created: https://notion.so/{page_id.replace("-","")}')9091if __name__ == '__main__':92 main()Error handling
{"object":"error","status":400,"code":"missing_version","message":"Notion-Version header is required."}The Notion-Version header was not included in the request.
Add Notion-Version: 2025-09-03 to all request headers. Define it once in a shared headers object and reuse it.
No retry — fix the header and resend.
{"object":"error","status":400,"code":"validation_error","message":"body.children[0].paragraph.rich_text is invalid"}Rich text content was passed as a plain string instead of an array of rich text objects. This is the most common mistake when building blocks programmatically.
Always structure rich text as an array: [{"type": "text", "text": {"content": "your text"}}]. Never pass a string directly as the rich_text value.
No retry — fix the block structure in your code.
{"object":"error","status":404,"code":"object_not_found","message":"Could not find page with ID: ..."}The parent page ID for the report, or the data source ID, is wrong or not shared with the integration.
Share the parent report archive page with the integration via Connections in Notion. Verify the page ID by opening the page and copying the UUID from the URL.
No retry — share the page first.
{"object":"error","status":429,"code":"rate_limited","message":"You have been rate limited."}More than 3 requests per second were sent. A report that paginates 5 pages of data and then appends 20 blocks can easily hit this if not throttled.
Add 350ms delays between all sequential API calls. For block appends, the 350ms delay between batches of 100 is sufficient.
Read the Retry-After header and sleep for that duration. Implement exponential backoff: start at the Retry-After value, double up to 64s max.
{"object":"error","status":409,"code":"conflict_error","message":"Conflict"}A data collision or transient file-upload provider issue occurred. Notion uses 409 for some transient infrastructure errors.
This error is retryable. Implement exponential backoff starting at 2 seconds.
Retry with exponential backoff: 2s, 4s, 8s, 16s, 32s. Log the page_id and retry count.
Rate Limits for Notion API
| Scope | Limit | Window |
|---|---|---|
| Per integration token | 3 requests average | per second (~2,700 per 15 minutes) |
| Block children per PATCH request | 100 blocks | per request |
| Rich text string length | 2,000 characters | per rich text object |
1import time2import requests34def notion_request_with_retry(method, url, headers, max_retries=5, **kwargs):5 for attempt in range(max_retries):6 resp = getattr(requests, method)(url, headers=headers, **kwargs)7 if resp.status_code == 429:8 wait = int(resp.headers.get('Retry-After', 2 ** attempt))9 print(f'Rate limited. Retrying in {wait}s (attempt {attempt+1})')10 time.sleep(wait)11 continue12 if resp.status_code == 409:13 wait = 2 ** attempt14 time.sleep(wait)15 continue16 return resp17 raise Exception(f'Max retries exceeded for {url}')- Add 350ms delays between all sequential Notion API calls to stay under 3 req/s average
- Cache database schemas and data source IDs — they change rarely and each schema fetch uses a rate-limited request slot
- Use date filters in POST /v1/data_sources/{id}/query to reduce result set size rather than fetching all rows and filtering in code
- Split large block appends into batches of 100 with 350ms delays between batches
- Run weekly reports during off-peak hours (e.g., Sunday midnight) to avoid competing with interactive workspace usage
Security checklist
- Store NOTION_TOKEN, NOTION_DATA_SOURCE_ID, and NOTION_REPORTS_PAGE_ID in environment variables, never in source code
- Enable only Read content and Insert content capabilities — do not enable Update content unless the automation also modifies existing rows
- Share only the specific source database and the reports archive page with the integration
- Do not expose the integration token in logs, API responses, or error messages
- Rotate the token via notion.so/my-integrations if it is ever committed to a public repository
- When running via CI/CD, use the platform's secret management (GitHub Actions secrets, AWS SSM, etc.) to inject the token at runtime
- Review shared pages quarterly and remove access for integrations that are no longer needed
Automation use cases
Monthly Revenue Dashboard
intermediateQuery a CRM or billing database monthly, aggregate revenue by client and tier, and write a formatted report page with totals and a ranked client list.
Sprint Velocity Report
intermediateAt the end of each sprint, count tasks completed vs planned, calculate story points delivered, and create a sprint retrospective page with completion rate and blockers list.
Multi-Database Executive Summary
advancedPull data from three separate Notion databases (sales, support tickets, ops tasks), aggregate across all three, and generate a weekly executive summary page combining all metrics.
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/monthZapier can query a Notion database and create a report page on a schedule, though aggregation requires multi-step Zaps with formatter actions.
- + No code required
- + Scheduled triggers built in
- + Connects to 5,000+ apps for sending reports
- - Aggregation logic is limited — complex sums require workarounds
- - Polling-based triggers add latency
- - Cost scales with task volume
Make
Free tier available; paid plans from $9/monthMake's Notion module supports database searches and page creation with schedule triggers, and its aggregator module handles summing values across results.
- + Built-in aggregator function for summing fields
- + More affordable for high-volume runs
- + Visual data transformation
- - Learning curve higher than Zapier
- - Notion module may lag behind API version updates
- - Complex reports need multiple modules
n8n
Free self-hosted; cloud from €20/monthn8n's Notion node and code node combination can paginate a database and write a report page, with the code node handling aggregation logic.
- + Self-hostable for free
- + Code node allows arbitrary aggregation logic
- + Cron trigger for scheduling
- - Requires self-hosting setup or paid cloud
- - Notion node may not support the latest API version
- - More setup than Zapier/Make
Best practices
- Always include Notion-Version: 2025-09-03 as a constant in your headers — never rely on the API defaulting to a version
- Paginate all database queries — never assume all rows fit in the first response, even for small databases
- Rich text is always an array of objects, not a string — define a helper function to construct rich text blocks to avoid repetition
- Batch block appends in groups of 100 and add 350ms delays between batches
- Store the report page URL from the POST /v1/pages response and log it — makes it easy to find reports without searching Notion
- Handle null property values defensively with optional chaining or null coalescing — any Notion property can be empty
- Run the automation on a schedule (cron) and send the report URL to your team via Slack or email so they know when new reports are available
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building a Notion database report automation in Python using the Notion API v2025-09-03. I query rows with POST /v1/data_sources/{id}/query and write the report back using POST /v1/pages and PATCH /v1/blocks/{id}/children. I'm getting a 400 validation_error when appending blocks. Here is the block structure I'm sending: {paste block array here}. The error message is: {paste error here}. What is wrong with my block structure? Note that rich_text must be an array of rich text objects, not a string.
Build a Next.js app that generates Notion database reports. The app should: authenticate with a Notion Bearer token from the NOTION_TOKEN environment variable, let the user input a DATA_SOURCE_ID and REPORTS_PAGE_ID, trigger a report generation that calls POST /v1/data_sources/{id}/query to fetch all rows with a date filter, aggregate the Revenue and Hours number properties, create a report page via POST /v1/pages, and append formatted summary blocks via PATCH /v1/blocks/{id}/children. Show a loading state during generation and display the final report URL when done. Use Notion-Version: 2025-09-03 on all API calls.
Frequently asked questions
Why does Notion's API not aggregate values for me?
The Notion API returns raw row data only — there is no built-in SUM, AVG, or COUNT endpoint. All aggregation must happen in your code after fetching rows. The only exception is the rollup property type, which Notion calculates within the UI, but rollup values still require separate API pagination to fully retrieve.
How do I handle databases with more than 100 rows?
Use cursor-based pagination. After each POST /v1/data_sources/{id}/query response, check the has_more boolean. If true, take the next_cursor value and include it as start_cursor in your next request. Repeat until has_more is false. There is no limit to the number of pages you can fetch — just respect the 3 req/s rate limit with delays between page requests.
What is the difference between POST /v1/pages (children) and PATCH /v1/blocks/{id}/children?
POST /v1/pages accepts a children array to add initial blocks at page creation time, limited to 100 blocks. PATCH /v1/blocks/{page_id}/children appends additional blocks to an existing page or block after creation, also limited to 100 per request. For reports with more than 100 blocks, use PATCH in multiple batches after creating the page.
Why does my rich text content get rejected with a validation_error?
Rich text in Notion is always an array of rich text objects, never a plain string. The correct structure is: [{"type": "text", "text": {"content": "your text"}}]. The most common mistake is passing a string directly: {"rich_text": "my text"} — this always fails. Define a helper function that wraps strings in the correct structure.
Is the Notion API free?
Yes, the Notion API is free with no paid tier. The rate limit (3 req/s) is the same regardless of your Notion plan. You can run unlimited automations as long as you stay within the rate limit.
Can I create a table block for the report instead of a bullet list?
Yes. Notion's table block has type 'table' with has_column_header and table_width fields, and child 'table_row' blocks with cells as arrays of rich text arrays. The structure is more complex than paragraph or list blocks — refer to developers.notion.com/reference/block#table for the exact schema. For most reports, a bulleted list or paragraph blocks are simpler and sufficient.
What happens when I hit the 3 req/s rate limit?
Notion returns HTTP 429 with body {"object":"error","status":429,"code":"rate_limited"} and a Retry-After header in seconds. Read the Retry-After value and sleep for that duration before retrying. To prevent hitting the limit, add 350ms delays between all sequential API calls in your automation.
Can RapidDev build a custom Notion reporting automation for my team?
Yes. RapidDev has built 600+ apps including multi-database Notion reporting pipelines, executive dashboards, and automated CRM report generators. We can build a fully managed solution tailored to your database schema. Visit rapidevelopers.com for a free consultation.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation