notion-api-macos-keychain
Skill1 starsChanged 2 months agoMITView on GitHub
This skill provides comprehensive instructions for interacting with the Notion API via REST calls. This skill should be used whenever the user asks to interact with Notion, including reading, creating, updating, or deleting pages, databases, blocks, comments, or any other Notion content. The skill covers authentication, all available endpoints, pagination, error handling, and best practices. Stores the Notion API token in the macOS Keychain under service `notion-api-token` and authenticates with a Bearer header against `https://api.notion.com`.
- Reads credentials
- Sends data out
In this repository2 other files it gives its agents.
- macos-keychain-credsSkill
- slide-creatorSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
17 rules. About 0 are common, 9 are distinctive, and 8 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈9
Distinctive · estimate
This author's own choices. Worth a look.
8
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 37
IMPORTANT: Never display, log, or send the token anywhere except in the Authorization header.
- Distinctive: line 53
Check the existing token first:
- Distinctive: line 60
If it is missing or invalid, ask the user for a Notion integration token, then store and validate clipboard contents without putting the …
- Distinctive: line 108
Do not print the token or enable shell tracing while the helper is active.
- Very specific: line 130
Empty Values: Use null instead of empty strings
- Distinctive: line 157
IMPORTANT: Before executing any operation that modifies or deletes data, ask the user for confirmation.
- Distinctive: line 170
Search across all accessible pages and databases:
- Very specific: line 264
Use the page ID as block_id to get page content.
- Distinctive: line 294
Default: appends to end
- Very specific: line 440
Use a page ID as block_id for page-level comments.
- Very specific: line 478
Check has_more in the response
- Very specific: line 479
If true, extract next_cursor and include it as start_cursor in the next request
- Distinctive: line 522
Store IDs: When creating pages/databases, store the returned IDs for future updates
- Very specific: line 526
Check
has_more: Always handle pagination for list endpoints - Very specific: line 528
Use Keychain: Never hardcode API keys; read the token via scripts/keychain.sh
- Distinctive: line 529
Handle Errors Gracefully: Check response status codes and error messages
- Distinctive: line 530
Schema Size: Keep database schemas under 50KB for optimal performance
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Notion API Skill
This skill enables interaction with Notion workspaces through the Notion REST API. Use the notion_curl helper for direct REST calls, or write ad-hoc scripts as appropriate for the task.
| Concern | Notion |
| Authentication | Authorization: Bearer <integration token> |
| Base URL | https://api.notion.com |
| API version | 2025-09-03 (required Notion-Version header) |
| Token storage | macOS Keychain service notion-api-token |
The skill is self-contained: scripts/keychain.sh holds the Keychain read/write helpers, so nothing else has to be installed.
Authentication
API Key Handling
The token is stored in the macOS Keychain, never in the environment or the repository.
- Check the stored token — run
scripts/check_token.shfirst - Store a token —
pbpaste | scripts/store_token.sh(validates againstGET /v1/users/mebefore writing, so a bad token cannot overwrite a working one) - Missing/Invalid — if no valid token is in Keychain, ask the user for a Notion integration token (from Notion → Settings → Connections → Develop or manage integrations)
IMPORTANT: Never display, log, or send the token anywhere except in the Authorization header. Confirm its existence, ask if missing, use it in requests—but never echo or expose it.
Configuration
Defaults:
NOTION_URL=https://api.notion.com
NOTION_VERSION=2025-09-03
KEYCHAIN_ACCOUNT=<current macOS username>Environment variables may override these values. NOTION_ENV_FILE may point to a local .env file containing only non-secret settings. Never put the token in .env or a repository.
Authentication setup
Check the existing token first:
notion_skill_dir="<directory containing this SKILL.md>"
"$notion_skill_dir/scripts/check_token.sh"If it is missing or invalid, ask the user for a Notion integration token, then store and validate clipboard contents without putting the literal token in shell history:
pbpaste | "$notion_skill_dir/scripts/store_token.sh"Removing the token is destructive and unrecoverable. Confirm the exact service with the user first, then:
security delete-generic-password -s "notion-api-token" -a "$(id -un)"Authenticated request helper
Define this helper once per shell session. It injects the Authorization and Notion-Version headers from Keychain, so request examples below never need to repeat them:
notion_curl() {
local env_file="${NOTION_ENV_FILE:-}"
if [[ -n "$env_file" ]]; then
# shellcheck disable=SC1090
set -a; . "$env_file"; set +a
fi
local notion_version="${NOTION_VERSION:-2025-09-03}"
local notion_skill_dir="<directory containing this SKILL.md>"
# shellcheck source=scripts/keychain.sh
source "$notion_skill_dir/scripts/keychain.sh"
local account="${KEYCHAIN_ACCOUNT:-$(id -un)}"
local token
token=$(keychain_get "notion-api-token" "$account") || {
echo "No Notion token in Keychain. Run scripts/store_token.sh first." >&2
return 1
}
curl -sS \
-H "Authorization: Bearer $token" \
-H "Notion-Version: $notion_version" \
"$@"
}Validate the session:
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users/me" | jq '{name, bot: .bot.workspace_name}'Do not print the token or enable shell tracing while the helper is active.
Request Headers
The notion_curl helper adds all required headers automatically. A raw request looks like:
-H "Authorization: Bearer $token" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json"For raw calls (e.g. in scripts that read the token via keychain_get), include all three.
Base URL and Conventions
- Base URL:
https://api.notion.com - API Version:
2025-09-03(required header) - Data Format: JSON for all request/response bodies
- IDs: UUIDv4 format (dashes optional in requests)
- Timestamps: ISO 8601 format (
2020-08-12T02:12:33.231Z) - Property Names:
snake_case - Empty Values: Use
nullinstead of empty strings
Rate Limits
- Average: 3 requests per second per integration
- Bursts: Brief bursts above this limit are allowed
- Rate Limited Response: HTTP 429 with
Retry-Afterheader - Strategy: Implement exponential backoff when receiving 429 responses
Request Size Limits
| Type | Limit |
| Maximum block elements per payload | 1000 |
| Maximum payload size | 500KB |
| Rich text content | 2000 characters |
| URLs | 2000 characters |
| Equations | 1000 characters |
| Email addresses | 200 characters |
| Phone numbers | 200 characters |
| Multi-select options | 100 items |
| Relations | 100 related pages |
| People mentions | 100 users |
| Block arrays per request | 100 elements |
Confirmation for Destructive Operations
IMPORTANT: Before executing any operation that modifies or deletes data, ask the user for confirmation. This includes:
- Updating pages or blocks
- Deleting/archiving pages or blocks
- Modifying database schemas
- Creating pages (if multiple or in batch)
- Any bulk operations
For a logical group of related operations, a single confirmation is sufficient.
Core API Endpoints
Search
Search across all accessible pages and databases:
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/search" \
-H "Content-Type: application/json" \
-d '{
"query": "search term",
"filter": {"property": "object", "value": "page"},
"sort": {"direction": "descending", "timestamp": "last_edited_time"},
"page_size": 100
}' | jqFilter values: "page" or "data_source" (or omit for both)
Pages
Retrieve a Page
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}" | jqNote: This returns page properties, not content. For content, use "Retrieve block children" with the page ID.
Create a Page
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/pages" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "parent-page-id"},
"properties": {
"title": {
"title": [{"text": {"content": "Page Title"}}]
}
},
"children": [
{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": "Paragraph content"}}]
}
}
]
}' | jqParent options:
{"page_id": "..."}- Create under a page{"database_id": "..."}- Create in a database (legacy){"data_source_id": "..."}- Create in a data source (API v2025-09-03+)
Update a Page
notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"title": {"title": [{"text": {"content": "Updated Title"}}]}
},
"icon": {"type": "emoji", "emoji": "📝"},
"archived": false
}' | jqAdditional update options: cover, is_locked, in_trash
Archive (Delete) a Page
notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}" \
-H "Content-Type: application/json" \
-d '{"archived": true}' | jqRetrieve a Page Property Item
For properties with more than 25 references:
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/pages/{page_id}/properties/{property_id}" | jqBlocks (Page Content)
Retrieve Block Children
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}/children?page_size=100" | jqUse the page ID as block_id to get page content. Check has_children on each block for nested content.
Append Block Children
notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}/children" \
-H "Content-Type: application/json" \
-d '{
"children": [
{
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [{"type": "text", "text": {"content": "New Section"}}]
}
},
{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": "Content here"}}]
}
}
]
}' | jqMaximum 100 blocks per request, up to 2 levels of nesting.
Position options in request body:
- Default: appends to end
"position": {"type": "start"}- Insert at beginning"position": {"type": "after_block", "after_block": {"id": "block-id"}}- Insert after specific block
Retrieve a Block
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}" | jqUpdate a Block
notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}" \
-H "Content-Type: application/json" \
-d '{
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": "Updated content"}}]
}
}' | jqThe update replaces the entire value for the specified field.
Delete a Block
notion_curl -X DELETE "${NOTION_URL:-https://api.notion.com}/v1/blocks/{block_id}" | jqMoves block to trash (can be restored).
Databases
Retrieve a Database
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/databases/{database_id}" | jqReturns database structure including data sources and properties.
Query a Database
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/databases/{database_id}/query" \
-H "Content-Type: application/json" \
-d '{
"filter": {
"property": "Status",
"select": {"equals": "Done"}
},
"sorts": [
{"property": "Created", "direction": "descending"}
],
"page_size": 100
}' | jqSee references/filters-and-sorts.md for comprehensive filter and sort documentation.
Create a Database
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/databases" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "parent-page-id"},
"title": [{"type": "text", "text": {"content": "My Database"}}],
"is_inline": true,
"initial_data_source": {
"properties": {
"Name": {"title": {}},
"Status": {
"select": {
"options": [
{"name": "To Do", "color": "red"},
{"name": "In Progress", "color": "yellow"},
{"name": "Done", "color": "green"}
]
}
},
"Due Date": {"date": {}}
}
}
}' | jqUpdate a Database
notion_curl -X PATCH "${NOTION_URL:-https://api.notion.com}/v1/databases/{database_id}" \
-H "Content-Type: application/json" \
-d '{
"title": [{"text": {"content": "Updated Title"}}],
"description": [{"text": {"content": "Database description"}}]
}' | jqData Sources (API v2025-09-03+)
Data sources are individual tables within a database. As of API version 2025-09-03, databases can contain multiple data sources.
Create a Data Source
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/data_sources" \
-H "Content-Type: application/json" \
-d '{
"parent": {"type": "database_id", "database_id": "database-id"},
"title": [{"type": "text", "text": {"content": "New Data Source"}}],
"properties": {
"Name": {"title": {}},
"Description": {"rich_text": {}}
}
}' | jqUsers
List All Users
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users?page_size=100" | jqRetrieve a User
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users/{user_id}" | jqRetrieve Bot User (Self)
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/users/me" | jqComments
Retrieve Comments
notion_curl "${NOTION_URL:-https://api.notion.com}/v1/comments?block_id={block_id}&page_size=100" | jqUse a page ID as block_id for page-level comments.
Create a Comment
On a page:
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/comments" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "page-id"},
"rich_text": [{"type": "text", "text": {"content": "Comment content"}}]
}' | jqReply to a discussion:
notion_curl -X POST "${NOTION_URL:-https://api.notion.com}/v1/comments" \
-H "Content-Type: application/json" \
-d '{
"discussion_id": "discussion-id",
"rich_text": [{"type": "text", "text": {"content": "Reply content"}}]
}' | jqNote: The API cannot start new inline discussion threads or edit/delete existing comments.
Pagination
Paginated endpoints return:
has_more: Boolean indicating more results existnext_cursor: Cursor for the next pageresults: Array of items
To iterate through all results:
- Make the initial request (omit
start_cursor) - Check
has_morein the response - If
true, extractnext_cursorand include it asstart_cursorin the next request - Repeat until
has_moreisfalse
Example request with cursor:
{
"page_size": 100,
"start_cursor": "v1%7C..."
}Error Handling
| HTTP Status | Code | Description |
| 400 | invalid_json | Request body is not valid JSON |
| 400 | invalid_request_url | URL is malformed |
| 400 | invalid_request | Request is not supported |
| 400 | validation_error | Request body doesn't match expected schema |
| 400 | missing_version | Missing Notion-Version header |
| 401 | unauthorized | Invalid bearer token |
| 403 | restricted_resource | Token lacks permission |
| 404 | object_not_found | Resource doesn't exist or not shared with integration |
| 409 | conflict_error | Data collision during transaction |
| 429 | rate_limited | Rate limit exceeded (check Retry-After header) |
| 500 | internal_server_error | Unexpected server error |
| 503 | service_unavailable | Notion unavailable or 60s timeout exceeded |
| 503 | database_connection_unavailable | Database unresponsive |
| 504 | gateway_timeout | Request timeout |
Troubleshooting
| Symptom | Likely cause | Action |
| No Keychain entry | Token was never stored for this account | Run store_token.sh |
401 unauthorized | Missing, expired, revoked, or mistyped token | Store a fresh integration token |
403 restricted_resource | Token lacks access to the resource | Share the page/database with the integration |
404 object_not_found | Wrong ID or resource not shared with integration | Verify IDs and sharing |
400 missing_version | Notion-Version header missing | Use the notion_curl helper |
Best Practices
- Store IDs: When creating pages/databases, store the returned IDs for future updates
- Use Property IDs: Reference properties by ID rather than name for stability
- Batch Operations: Aggregate multiple small operations into fewer requests
- Respect Rate Limits: Implement exponential backoff for 429 responses
- Check
has_more: Always handle pagination for list endpoints - Validate Before Updates: Retrieve current state before making updates
- Use Keychain: Never hardcode API keys; read the token via
scripts/keychain.sh - Handle Errors Gracefully: Check response status codes and error messages
- Schema Size: Keep database schemas under 50KB for optimal performance
- Properties Limit: Properties with >25 page references require separate retrieval
References
For detailed documentation on specific topics, see:
references/block-types.md- All supported block types and their structuresreferences/property-types.md- Database property types and value formatsreferences/filters-and-sorts.md- Database query filter and sort syntaxreferences/rich-text.md- Rich text object structure and annotations
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

