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

How to Automate Notion Task Management using the API

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.

Need help automating? Talk to an expert
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner7 min read15-30 minutesNotionMay 2026RapidDev Engineering Team
TL;DR

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

Auth

Bearer Token

Rate limit

3 requests/second

Format

JSON

SDK

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.

Base URLhttps://api.notion.com

Setting 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.

  1. 1Go to notion.so/my-integrations (you must be a workspace owner)
  2. 2Click 'New integration' and give it a name (e.g., 'Task Automation')
  3. 3Under Capabilities, enable: Read content, Update content, Insert content
  4. 4Click Save and copy the Internal Integration Secret (starts with ntn_)
  5. 5Open your task database in Notion, click the '...' menu at the top right
  6. 6Click 'Connections' and search for your integration name, then click Connect
  7. 7Store the token in an environment variable: export NOTION_TOKEN=ntn_your_token_here
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
12# Verify auth works by fetching the integration's bot user
13response = 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

POST/v1/data_sources/{data_source_id}/query

Query 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.

ParameterTypeRequiredDescription
filterobjectoptionalFilter object using property-specific filter conditions (status, date, checkbox, etc.)
sortsarrayoptionalArray of sort objects with property name and direction (ascending/descending)
page_sizenumberoptionalNumber of results per page, max 100. Default is 100.
start_cursorstringoptionalCursor from a previous response's next_cursor to fetch the next page

Request

json
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

json
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}
PATCH/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.

ParameterTypeRequiredDescription
propertiesobjectoptionalMap of property names to property value objects. Each property type (status, select, date, people, etc.) has a different shape.
archivedbooleanoptionalSet to true to move the page to trash (renamed from archived to in_trash in API version 2026-03-11)

Request

json
1{"properties":{"Status":{"status":{"name":"Overdue"}}}}

Response

json
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"}
GET/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.

ParameterTypeRequiredDescription
database_idstringrequiredThe UUID of the Notion database. Extract it from the URL: notion.so/workspace/DATABASE_ID?v=...

Response

json
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

1

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.

request.sh
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.

2

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.

request.sh
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": 100
14 }'

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.

3

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.

request.sh
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.

4

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.

request.sh
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.

automate_notion_tasks.py
1import os
2import time
3import requests
4from datetime import date
5
6NOTION_TOKEN = os.environ['NOTION_TOKEN']
7DATA_SOURCE_ID = os.environ['NOTION_DATA_SOURCE_ID']
8SLACK_WEBHOOK_URL = os.environ.get('SLACK_WEBHOOK_URL')
9
10HEADERS = {
11 'Authorization': f'Bearer {NOTION_TOKEN}',
12 'Notion-Version': '2025-09-03',
13 'Content-Type': 'application/json'
14}
15
16def query_overdue_tasks():
17 today = date.today().isoformat()
18 tasks = []
19 cursor = None
20 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': 100
27 }
28 if cursor:
29 payload['start_cursor'] = cursor
30 resp = requests.post(
31 f'https://api.notion.com/v1/data_sources/{DATA_SOURCE_ID}/query',
32 headers=HEADERS, json=payload
33 )
34 resp.raise_for_status()
35 data = resp.json()
36 tasks.extend(data['results'])
37 if not data['has_more']:
38 break
39 cursor = data['next_cursor']
40 return tasks
41
42def 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()
54
55def post_to_slack(task_names):
56 if not task_names or not SLACK_WEBHOOK_URL:
57 return
58 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 })
64
65def 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.')
79
80if __name__ == '__main__':
81 main()

Error handling

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

The Notion-Version header was omitted from the request.

Fix

Add 'Notion-Version: 2025-09-03' to every request header. This is mandatory on all API calls.

Retry strategy

No retry needed — fix the request header and resend.

400{"object":"error","status":400,"code":"validation_error","message":"Invalid option \"Overdue\" for status property."}
Cause

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.

Fix

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.

Retry strategy

No retry needed — fix the status name in your payload.

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

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.

Fix

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.

Retry strategy

No retry — share the database with the integration first.

429{"object":"error","status":429,"code":"rate_limited","message":"You have been rate limited. Please try again later."}
Cause

More than approximately 3 requests per second were sent by this integration.

Fix

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.

Retry strategy

Honor the Retry-After header. Implement exponential backoff starting at the Retry-After value, doubling up to 60s max.

403{"object":"error","status":403,"code":"restricted_resource","message":"Insufficient permissions for this endpoint."}
Cause

The integration does not have the required capability enabled (e.g., Update content is disabled but you are trying to PATCH a page).

Fix

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.

Retry strategy

No retry — fix the integration capabilities first.

Rate Limits for Notion API

ScopeLimitWindow
Per integration token3 requests averageper second (approximately 2,700 per 15 minutes)
Block children per request100 blocksper PATCH /v1/blocks/{id}/children call
Rich text string2,000 charactersper individual rich text object
retry-handler.ts
1import time
2import requests
3
4def 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 continue
12 return resp
13 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

beginner

Query 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

intermediate

Check 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

advanced

At 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/month

Zapier's Notion integration supports triggers on database row updates and actions to update properties, making it easy to build status-change automations without code.

Pros
  • + No code required
  • + Visual workflow builder
  • + Large library of connected apps for notifications
Cons
  • - 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/month

Make (formerly Integromat) has a Notion module with database search and page update actions, and supports scheduled triggers for periodic polling.

Pros
  • + More affordable than Zapier for high-volume automations
  • + Visual scenario builder
  • + Better error handling controls
Cons
  • - 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/month

n8n has a Notion node that supports database queries and page updates, and can be self-hosted for full control over scheduling and execution.

Pros
  • + Self-hostable (free)
  • + Full control over execution frequency
  • + Can combine with code nodes for complex logic
Cons
  • - 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.

ChatGPT / Claude Prompt

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?

Lovable / V0 Prompt

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.

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.