Automate Notion task management by polling overdue tasks with POST /v1/data_sources/{id}/query and updating their status property via PATCH /v1/pages/{id}. Every request must include the Notion-Version: 2025-09-03 header or you get a 400 missing_version error. The key rate limit is 3 requests/second per integration — batching status updates for large boards requires careful throttling.
API Quick Reference
Bearer Token
3 requests/second
JSON
Available
Understanding the Notion API
The Notion API is a REST API that lets you read and write to any Notion workspace your integration has been granted access to. As of API version 2025-09-03, databases were restructured: a 'database' is now a container that holds one or more 'data sources,' and row-level query operations moved from /v1/databases/{id}/query to /v1/data_sources/{id}/query. Any tutorial referencing the old databases query endpoint is out of date for this version.
Task management automation centers on three operations: querying tasks by filter (e.g., all items where status is 'To Do' and due date is past), patching page properties to change status or assignees, and reading database schema to understand property structures. The Notion API uses cursor-based pagination with a maximum of 100 results per page.
Official documentation is at developers.notion.com. All requests must include the Notion-Version header — omitting it returns HTTP 400 with code missing_version.
https://api.notion.comSetting Up Notion API Authentication
Notion uses long-lived Bearer tokens for internal integrations — there is no OAuth flow required for automating your own workspace. Tokens issued on or after 2024-09-25 use the ntn_ prefix. The token must be passed as an Authorization: Bearer header on every request alongside the mandatory Notion-Version header.
- 1Go to notion.so/my-integrations (you must be a workspace owner)
- 2Click 'New integration' and give it a name (e.g., 'Task Automation')
- 3Under Capabilities, enable: Read content, Update content, Insert content
- 4Click Save and copy the Internal Integration Secret (starts with ntn_)
- 5Open your task database in Notion, click the '...' menu at the top right
- 6Click 'Connections' and search for your integration name, then click Connect
- 7Store the token in an environment variable: export NOTION_TOKEN=ntn_your_token_here
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 auth works by fetching the integration's bot user13response = requests.get('https://api.notion.com/v1/users/me', headers=headers)14if response.status_code == 200:15 print('Auth OK:', response.json()['name'])16else:17 print('Auth failed:', response.status_code, response.text)Security notes
- •Store the integration token in environment variables, never hardcode it in source files
- •Use the minimum capabilities needed — if you only read tasks, disable Insert and Update
- •Notion integration tokens do not expire for internal integrations, but rotate them if compromised via notion.so/my-integrations
- •Only share the specific databases the integration needs — not your entire workspace
- •Never log or print the raw token value; log only masked versions like ntn_****
Key endpoints
/v1/data_sources/{data_source_id}/queryQuery rows from a Notion data source (formerly database) with filter, sort, and pagination. This is the correct endpoint for API version 2025-09-03 — the old /v1/databases/{id}/query is from the 2022-06-28 version.
| Parameter | Type | Required | Description |
|---|---|---|---|
filter | object | optional | Filter object using property-specific filter conditions (status, date, checkbox, etc.) |
sorts | array | optional | Array of sort objects with property name and direction (ascending/descending) |
page_size | number | optional | Number of results per page, max 100. Default is 100. |
start_cursor | string | optional | Cursor from a previous response's next_cursor to fetch the next page |
Request
1{"filter":{"and":[{"property":"Status","status":{"equals":"To Do"}},{"property":"Due Date","date":{"before":"2026-05-07"}}]},"sorts":[{"property":"Due Date","direction":"ascending"}],"page_size":100}Response
1{"object":"list","results":[{"object":"page","id":"page-id-here","properties":{"Name":{"title":[{"plain_text":"Fix login bug"}]},"Status":{"status":{"name":"To Do"}},"Due Date":{"date":{"start":"2026-05-06"}}}}],"next_cursor":null,"has_more":false}/v1/pages/{page_id}Update properties of an existing Notion page (task). Use this to change the Status, Assignee, Due Date, or any other property. Each property type has its own payload shape.
| Parameter | Type | Required | Description |
|---|---|---|---|
properties | object | optional | Map of property names to property value objects. Each property type (status, select, date, people, etc.) has a different shape. |
archived | boolean | optional | Set to true to move the page to trash (renamed from archived to in_trash in API version 2026-03-11) |
Request
1{"properties":{"Status":{"status":{"name":"Overdue"}}}}Response
1{"object":"page","id":"page-id-here","properties":{"Status":{"id":"abc","type":"status","status":{"id":"status-id","name":"Overdue","color":"red"}}},"last_edited_time":"2026-05-07T10:00:00.000Z"}/v1/databases/{database_id}Retrieve a database container's schema, including all property definitions and the list of data sources. Use this to discover property names and valid status option names before querying or updating.
| Parameter | Type | Required | Description |
|---|---|---|---|
database_id | string | required | The UUID of the Notion database. Extract it from the URL: notion.so/workspace/DATABASE_ID?v=... |
Response
1{"object":"database","id":"db-id-here","title":[{"plain_text":"Tasks"}],"properties":{"Status":{"id":"prop-id","type":"status","status":{"options":[{"id":"opt1","name":"To Do","color":"gray"},{"id":"opt2","name":"In Progress","color":"blue"},{"id":"opt3","name":"Done","color":"green"},{"id":"opt4","name":"Overdue","color":"red"}]}}},"data_sources":[{"id":"ds-id-here"}]}Step-by-step automation
Fetch Database Schema to Discover Property Names
Why: Property names and status option names are case-sensitive — getting them wrong causes a 400 validation_error when you try to filter or update.
Before writing your query filter, fetch the database schema with GET /v1/databases/{id}. This returns all property names and their types, including the exact option names for Status and Select properties. You only need to do this once — cache the schema and reuse it. The database ID is in the Notion URL: the UUID after the last slash and before the query string.
1curl -X GET 'https://api.notion.com/v1/databases/YOUR_DATABASE_ID' \2 -H 'Authorization: Bearer $NOTION_TOKEN' \3 -H 'Notion-Version: 2025-09-03'Pro tip: Status option names are case-sensitive. If your database has 'In Progress' with a capital I, your filter must match exactly. A 400 validation_error here is almost always a name mismatch.
Expected result: JSON response with all property definitions. Extract the data_sources[0].id value — you will use this as the data_source_id in the query endpoint.
Query Overdue Tasks with a Filter
Why: Polling overdue tasks selectively means your automation only processes what it needs to act on, rather than fetching all tasks and filtering in code.
Send POST /v1/data_sources/{data_source_id}/query with a compound filter: status equals 'To Do' AND due date is before today. The response is paginated (max 100 per page) — check has_more and use next_cursor to iterate through all pages if your board has more than 100 tasks.
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": {7 "and": [8 {"property": "Status", "status": {"equals": "To Do"}},9 {"property": "Due Date", "date": {"before": "2026-05-07"}}10 ]11 },12 "sorts": [{"property": "Due Date", "direction": "ascending"}],13 "page_size": 10014 }'Pro tip: The filter uses AND logic by default inside the 'and' array. For OR logic, use the 'or' key instead. Compound filters are limited to 2 nesting levels — deeply nested filters are not supported.
Expected result: A list of page objects where each result has an id and a properties object. The task name is under properties.Name.title[0].plain_text.
Update Task Status to Overdue
Why: Changing the status via the API is what closes the loop — it makes the board accurately reflect reality without any manual drag-and-drop.
For each overdue task returned in the previous step, send PATCH /v1/pages/{page_id} with the updated status property. Respect the 3 req/s rate limit by adding a short delay between requests when processing many tasks. The status property value is always an object like {status: {name: 'Overdue'}} — not a string.
1curl -X PATCH 'https://api.notion.com/v1/pages/PAGE_ID_HERE' \2 -H 'Authorization: Bearer $NOTION_TOKEN' \3 -H 'Notion-Version: 2025-09-03' \4 -H 'Content-Type: application/json' \5 -d '{"properties": {"Status": {"status": {"name": "Overdue"}}}}'Pro tip: If your Notion database does not have an 'Overdue' status option, the PATCH returns a 400 validation_error. Add the status option in Notion first, or check your database schema to find the exact option name.
Expected result: Each PATCH returns the updated page object with the new status value reflected. You can verify by checking result.properties.Status.status.name.
Post Summary to Webhook
Why: Notifying your team via Slack, Discord, or email closes the feedback loop so the status change is actionable.
After updating all tasks, build a summary payload and POST it to your webhook URL. Include the count and names of updated tasks. This can be a Slack incoming webhook, a Discord webhook, or any HTTP endpoint you control.
1curl -X POST 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK' \2 -H 'Content-Type: application/json' \3 -d '{"text": ":warning: 3 Notion tasks moved to Overdue: Fix login bug, Update docs, Review PR"}'Pro tip: Store the webhook URL in an environment variable, not in your script. Slack webhook URLs carry full write access to the channel.
Expected result: Your Slack or Discord channel receives a message listing the tasks that were marked as overdue.
Complete working code
This complete script polls a Notion task database for items with status 'To Do' that are past their due date, marks them all as 'Overdue', and posts a summary to a Slack webhook. It handles pagination for large boards and respects the 3 req/s rate limit with 350ms delays between PATCH calls.
1import os2import time3import requests4from datetime import date56NOTION_TOKEN = os.environ['NOTION_TOKEN']7DATA_SOURCE_ID = os.environ['NOTION_DATA_SOURCE_ID']8SLACK_WEBHOOK_URL = os.environ.get('SLACK_WEBHOOK_URL')910HEADERS = {11 'Authorization': f'Bearer {NOTION_TOKEN}',12 'Notion-Version': '2025-09-03',13 'Content-Type': 'application/json'14}1516def query_overdue_tasks():17 today = date.today().isoformat()18 tasks = []19 cursor = None20 while True:21 payload = {22 'filter': {'and': [23 {'property': 'Status', 'status': {'equals': 'To Do'}},24 {'property': 'Due Date', 'date': {'before': today}}25 ]},26 'page_size': 10027 }28 if cursor:29 payload['start_cursor'] = cursor30 resp = requests.post(31 f'https://api.notion.com/v1/data_sources/{DATA_SOURCE_ID}/query',32 headers=HEADERS, json=payload33 )34 resp.raise_for_status()35 data = resp.json()36 tasks.extend(data['results'])37 if not data['has_more']:38 break39 cursor = data['next_cursor']40 return tasks4142def update_status(page_id, status_name):43 resp = requests.patch(44 f'https://api.notion.com/v1/pages/{page_id}',45 headers=HEADERS,46 json={'properties': {'Status': {'status': {'name': status_name}}}}47 )48 if resp.status_code == 429:49 retry_after = int(resp.headers.get('Retry-After', 5))50 time.sleep(retry_after)51 return update_status(page_id, status_name)52 resp.raise_for_status()53 return resp.json()5455def post_to_slack(task_names):56 if not task_names or not SLACK_WEBHOOK_URL:57 return58 names = ', '.join(task_names[:10])59 if len(task_names) > 10:60 names += f' ...and {len(task_names) - 10} more'61 requests.post(SLACK_WEBHOOK_URL, json={62 'text': f':warning: {len(task_names)} task(s) marked Overdue: {names}'63 })6465def main():66 print('Querying overdue tasks...')67 tasks = query_overdue_tasks()68 print(f'Found {len(tasks)} overdue tasks')69 updated_names = []70 for task in tasks:71 title = task['properties'].get('Name', {}).get('title', [])72 name = title[0]['plain_text'] if title else 'Untitled'73 update_status(task['id'], 'Overdue')74 updated_names.append(name)75 print(f' Updated: {name}')76 time.sleep(0.35)77 post_to_slack(updated_names)78 print('Done.')7980if __name__ == '__main__':81 main()Error handling
{"object":"error","status":400,"code":"missing_version","message":"Notion-Version header is required."}The Notion-Version header was omitted from the request.
Add 'Notion-Version: 2025-09-03' to every request header. This is mandatory on all API calls.
No retry needed — fix the request header and resend.
{"object":"error","status":400,"code":"validation_error","message":"Invalid option \"Overdue\" for status property."}The status option name used in the filter or PATCH payload does not match any existing option in the database. Status option names are case-sensitive.
Fetch the database schema with GET /v1/databases/{id} and check properties.Status.status.options for the exact option names. Create the 'Overdue' option in Notion if it does not exist.
No retry needed — fix the status name in your payload.
{"object":"error","status":404,"code":"object_not_found","message":"Could not find database with ID: ..."}The database or page has not been shared with the integration, OR the ID is wrong. Notion returns 404 (not 403) for resources not shared with the integration.
Open the database in Notion, click '...' > Connections, and connect your integration. Also verify the database ID — it is the UUID in the URL before any query string.
No retry — share the database with the integration first.
{"object":"error","status":429,"code":"rate_limited","message":"You have been rate limited. Please try again later."}More than approximately 3 requests per second were sent by this integration.
Read the Retry-After header value (integer seconds) and sleep for that duration before retrying. Add 350ms delays between sequential requests to stay under the limit proactively.
Honor the Retry-After header. Implement exponential backoff starting at the Retry-After value, doubling up to 60s max.
{"object":"error","status":403,"code":"restricted_resource","message":"Insufficient permissions for this endpoint."}The integration does not have the required capability enabled (e.g., Update content is disabled but you are trying to PATCH a page).
Go to notion.so/my-integrations, open your integration, and enable the required capabilities (Read content, Update content, Insert content). Re-share the database after updating capabilities.
No retry — fix the integration capabilities first.
Rate Limits for Notion API
| Scope | Limit | Window |
|---|---|---|
| Per integration token | 3 requests average | per second (approximately 2,700 per 15 minutes) |
| Block children per request | 100 blocks | per PATCH /v1/blocks/{id}/children call |
| Rich text string | 2,000 characters | per individual rich text object |
1import time2import requests34def notion_request(method, url, headers, **kwargs):5 for attempt in range(5):6 resp = getattr(requests, method)(url, headers=headers, **kwargs)7 if resp.status_code == 429:8 retry_after = int(resp.headers.get('Retry-After', 2 ** attempt))9 print(f'Rate limited. Waiting {retry_after}s...')10 time.sleep(retry_after)11 continue12 return resp13 raise Exception('Max retries exceeded')- Add 350ms delays between sequential requests to stay safely under the 3 req/s average
- Batch your status updates during off-peak hours to reduce the impact on interactive Notion users on the same workspace
- Cache the database schema (GET /v1/databases/{id}) locally — it does not change frequently and wastes a request slot each run
- Use the filter parameter in POST /v1/data_sources/{id}/query instead of fetching all pages and filtering in code
- Monitor the Retry-After header value — a persistently high value indicates you need to reduce your request frequency
Security checklist
- Store the Notion integration token in environment variables, never in source code or config files committed to version control
- Use the minimum required capabilities on the integration — if the automation only reads and updates, do not enable Insert content
- Share only the specific databases the integration needs — not your entire Notion workspace
- Notion internal integration tokens do not expire, but rotate them immediately if the token is exposed in logs, error messages, or a public repository
- Do not log the Authorization header value; log only the masked token like ntn_****
- When running on a server or CI system, inject the token via secret management (AWS Secrets Manager, GitHub Actions secrets, etc.), not environment files checked into git
- Validate that the data_source_id and database_id belong to the expected workspace before running bulk updates
Automation use cases
Daily Standup Report
beginnerQuery all tasks updated in the last 24 hours across status stages and post a daily standup summary to Slack with In Progress, Completed, and Blocked counts.
SLA Breach Alerting
intermediateCheck tasks that have been In Progress for more than N days (using the last_edited_time property) and escalate to a manager channel when SLA thresholds are breached.
Cross-Database Sprint Sync
advancedAt the start of a sprint, query a backlog database for tasks with a specific sprint tag, create linked pages in the active sprint database, and update both databases' relation properties to maintain bidirectional links.
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's Notion integration supports triggers on database row updates and actions to update properties, making it easy to build status-change automations without code.
- + No code required
- + Visual workflow builder
- + Large library of connected apps for notifications
- - Polling-based triggers with 5-15 min delay on free/Starter plans
- - Costs increase with task volume
- - Less control over pagination for large databases
Make
Free tier available; paid plans from $9/monthMake (formerly Integromat) has a Notion module with database search and page update actions, and supports scheduled triggers for periodic polling.
- + More affordable than Zapier for high-volume automations
- + Visual scenario builder
- + Better error handling controls
- - Steeper learning curve than Zapier
- - Polling delay on lower-tier plans
- - Complex filters require manual JSON configuration
n8n
Free self-hosted; cloud plans from €20/monthn8n has a Notion node that supports database queries and page updates, and can be self-hosted for full control over scheduling and execution.
- + Self-hostable (free)
- + Full control over execution frequency
- + Can combine with code nodes for complex logic
- - Requires server setup for self-hosting
- - Notion node may lag behind API version changes
- - Less polished UI than Zapier/Make
Best practices
- Always include the Notion-Version: 2025-09-03 header — pin this in a constants file so you never accidentally omit it
- Fetch the database schema once at startup to validate property names and option values before running bulk updates
- Use the filter parameter server-side rather than fetching all tasks and filtering in memory — this reduces both API calls and processing time
- Process status updates with 350ms delays between requests to stay under the 3 req/s rate limit even during burst operations
- Check response status_code before accessing the response body — do not assume a 200 response for every call
- Use the has_more and next_cursor fields to paginate large result sets rather than assuming all results fit in the first page
- Store the data_source_id alongside the database_id in your configuration — the two are different IDs and both are needed
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm automating Notion task management using the Notion API v2025-09-03. My Python script queries a data source with POST /v1/data_sources/{id}/query and updates page status with PATCH /v1/pages/{id}. I'm getting a 400 validation_error when updating the status property. Here is my request payload: {paste payload here}. Here is the database schema properties object: {paste properties here}. What is wrong with my status property value shape?
Build a Next.js dashboard that connects to the Notion API to display task management analytics. The app should: authenticate with a Notion Bearer token stored in environment variables, query a task database using POST /v1/data_sources/{id}/query, display tasks grouped by Status (To Do, In Progress, Done, Overdue) in Kanban-style columns using shadcn/ui Card components, show a count of overdue tasks at the top with a red badge, and include a 'Mark All Overdue' button that calls PATCH /v1/pages/{id} for each overdue task. Use the Notion-Version: 2025-09-03 header on all API calls. The database ID and token should come from NOTION_DATABASE_ID and NOTION_TOKEN env vars.
Frequently asked questions
What is the difference between /v1/databases/{id}/query and /v1/data_sources/{id}/query?
In Notion API version 2022-06-28, you queried database rows with POST /v1/databases/{id}/query. Starting with version 2025-09-03, Notion restructured databases as containers holding one or more 'data sources,' and row-level queries moved to POST /v1/data_sources/{id}/query. If you send the old query endpoint with Notion-Version: 2025-09-03, you will get a 404 or unexpected behavior. Fetch your database with GET /v1/databases/{id} to find the data_sources[0].id value.
Why do I get a 404 object_not_found even though the database exists?
Notion returns 404 (not 403) when a resource is not shared with your integration. Open the database in Notion, click the '...' menu, go to Connections, and connect your integration. The 404 behavior is intentional — Notion treats unshared pages as non-existent from the integration's perspective to avoid information leakage.
How do I get the data_source_id for my database?
Call GET /v1/databases/{database_id} and look at the data_sources array in the response. The first element's id is your data_source_id. The database_id is the UUID in the Notion URL: notion.so/workspace/DATABASE_ID?v=viewId. Copy only the UUID part, not the view parameter.
What happens when I hit the Notion API rate limit?
Notion returns HTTP 429 with a Retry-After header containing the number of seconds to wait. The body is {"object":"error","status":429,"code":"rate_limited","message":"..."}. Read the Retry-After value and sleep for that duration before retrying. To avoid hitting the limit, keep your request rate below 3 per second — a 350ms delay between sequential requests keeps you safely under the threshold.
Is the Notion API free?
Yes, the Notion API is free to use regardless of your Notion plan. There is no paid API tier, and the rate limit (3 req/s) is the same for all plans. However, some Notion features (like automation buttons and webhooks) may require paid workspace plans.
Can I update multiple task properties in a single PATCH request?
Yes. The PATCH /v1/pages/{id} endpoint accepts a properties object with multiple keys. For example: {"properties": {"Status": {"status": {"name": "Done"}}, "Due Date": {"date": {"start": "2026-05-10"}}}} updates both Status and Due Date in one request. This is more efficient than separate requests.
Why does my filter return no results even though I can see matching tasks in Notion?
The most common cause is a property name mismatch — Notion property names are case-sensitive. If your database has a property named 'due date' (lowercase) but your filter uses 'Due Date', the filter will silently return no matches. Fetch the database schema with GET /v1/databases/{id} and copy the exact property names from the properties object.
Can RapidDev help build a custom Notion integration?
Yes. RapidDev has built 600+ apps including numerous Notion automations for task management, CRM pipelines, and content workflows. We can build a fully managed integration tailored to your workspace structure. Reach out for a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation