clawskills-mcp 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.test.js +1 -1
- package/package.json +1 -1
- package/skills/INDEX.md +22 -1
- package/skills/notion/skill.md +800 -0
package/dist/index.test.js
CHANGED
|
@@ -185,7 +185,7 @@ const REQUIRED_SECTIONS = [
|
|
|
185
185
|
const KNOWN_SKILLS = [
|
|
186
186
|
"asana", "dynamics365", "figma", "github", "hubspot",
|
|
187
187
|
"jira", "monday", "salesforce", "servicenow", "zendesk",
|
|
188
|
-
"slack", "stripe",
|
|
188
|
+
"slack", "stripe", "notion",
|
|
189
189
|
];
|
|
190
190
|
describe("real skills", () => {
|
|
191
191
|
let skills;
|
package/package.json
CHANGED
package/skills/INDEX.md
CHANGED
|
@@ -22,6 +22,7 @@ A curated library of integration skill documents for the most common SaaS platfo
|
|
|
22
22
|
| Figma | [figma/skill.md](./figma/skill.md) | Design, Product, DevOps | Design file inspection, asset export, webhooks, design-to-dev handoff |
|
|
23
23
|
| Slack | [slack/skill.md](./slack/skill.md) | All teams | Real-time messaging, notifications, event-driven automation via Web API |
|
|
24
24
|
| Stripe | [stripe/skill.md](./stripe/skill.md) | Engineering, Finance, RevOps | Payments, subscriptions, refunds, webhooks, and Connect for platforms |
|
|
25
|
+
| Notion | [notion/skill.md](./notion/skill.md) | Product, Engineering, Ops | Docs, databases, and blocks API for knowledge base and project automation |
|
|
25
26
|
|
|
26
27
|
---
|
|
27
28
|
|
|
@@ -181,6 +182,20 @@ A curated library of integration skill documents for the most common SaaS platfo
|
|
|
181
182
|
|
|
182
183
|
---
|
|
183
184
|
|
|
185
|
+
### Notion → [skill.md](./notion/skill.md)
|
|
186
|
+
1. Create a database row (page with properties: title, select, date, people)
|
|
187
|
+
2. Query a database with AND/OR filters and sorts, paginating all results
|
|
188
|
+
3. Update page properties (select, date, checkbox, rich_text)
|
|
189
|
+
4. Archive (trash) a page
|
|
190
|
+
5. Create a page with rich content blocks (headings, callout, to-do, code)
|
|
191
|
+
6. Append blocks to an existing page in batches of 100
|
|
192
|
+
7. Read all blocks from a page recursively (handling has_children)
|
|
193
|
+
8. Search for pages and databases by title
|
|
194
|
+
9. Incremental sync — query pages modified since a given timestamp
|
|
195
|
+
10. Upsert pattern — create-or-update using an external ID property
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
184
199
|
### Stripe → [skill.md](./stripe/skill.md)
|
|
185
200
|
1. Create a PaymentIntent for a one-time charge and confirm with Stripe.js
|
|
186
201
|
2. Save a card for future off-session charges (SetupIntent → attach PaymentMethod)
|
|
@@ -213,6 +228,9 @@ These scenarios span multiple skill docs — reference both tools:
|
|
|
213
228
|
| Design ready → engineering task | Figma | Jira / Asana / GitHub | `DEV_MODE_STATUS_UPDATE` webhook → create/update issue with frame link |
|
|
214
229
|
| Asset pipeline | Figma | S3 / CDN | `FILE_VERSION_UPDATE` webhook → export frames as PNG, upload to storage |
|
|
215
230
|
| Payment succeeded → CRM deal closed | Stripe | Salesforce / HubSpot | `payment_intent.succeeded` webhook → update deal stage to Closed Won |
|
|
231
|
+
| Deal closed → Notion project page | Salesforce / HubSpot | Notion | Closed Won trigger → create onboarding project row in Notion database |
|
|
232
|
+
| GitHub release → Notion changelog | GitHub | Notion | `release` event → append block to changelog database page |
|
|
233
|
+
| Alert fired → Notion runbook page | PagerDuty / any webhook | Notion | Incident fires → create structured runbook page with checklist blocks |
|
|
216
234
|
| Subscription cancelled → support ticket | Stripe | Zendesk / Jira | `customer.subscription.deleted` → create ticket for churn review |
|
|
217
235
|
| Invoice paid → project provisioned | Stripe | Asana / Monday.com | `invoice.paid` webhook → create onboarding project or board item |
|
|
218
236
|
|
|
@@ -234,6 +252,7 @@ These scenarios span multiple skill docs — reference both tools:
|
|
|
234
252
|
| Figma | Personal Access Token (PAT) or OAuth 2.0 | `X-Figma-Token` (PAT) / `Authorization: Bearer` (OAuth) | Granular scopes: `file_content:read`, `webhooks:write`, etc. |
|
|
235
253
|
| Slack | Bot token (OAuth 2.0 app install) | `xoxb-...` Bearer token | Per-method scopes: `chat:write`, `channels:read`, `users:read`, etc. |
|
|
236
254
|
| Stripe | Secret API key or Restricted key | `Authorization: Bearer sk_live_...` | Per-resource scopes on restricted keys (None / Read / Write) |
|
|
255
|
+
| Notion | Internal Integration Secret or OAuth token | `Authorization: Bearer secret_...` | Per-capability: Read/Insert/Update content, Read users |
|
|
237
256
|
|
|
238
257
|
---
|
|
239
258
|
|
|
@@ -253,10 +272,11 @@ These scenarios span multiple skill docs — reference both tools:
|
|
|
253
272
|
| Figma | Starter: 10/min (T1), 25/min (T2) · Professional: 15/min (T1), 50/min (T2) · Org: 20/min (T1), 100/min (T2) | Per minute (leaky bucket) | `Retry-After` / `X-Figma-Rate-Limit-Type` / `X-Figma-Plan-Tier` |
|
|
254
273
|
| Slack | Tier 1: 1/min · Tier 2: 20/min · Tier 3: 50/min · Tier 4: 100/min · `chat.postMessage`: 1/sec/channel sub-limit | Per method per workspace | HTTP 429 + `Retry-After` header (seconds) |
|
|
255
274
|
| Stripe | Live: **100 req/sec** · Sandbox: **25 req/sec** · Search: 20/sec | Per account | HTTP 429 + `Stripe-Rate-Limited-Reason` header |
|
|
275
|
+
| Notion | **3 req/sec average** (burst allowed) | Per workspace | HTTP 429 + `Retry-After` header (seconds) |
|
|
256
276
|
|
|
257
277
|
---
|
|
258
278
|
|
|
259
|
-
*Last updated: 2026-03-
|
|
279
|
+
*Last updated: 2026-03-02 (Notion skill added) | See [ROADMAP.md](./ROADMAP.md) for versioning and governance details.*
|
|
260
280
|
|
|
261
281
|
### API version reference (as of 2026-02-22)
|
|
262
282
|
|
|
@@ -274,3 +294,4 @@ These scenarios span multiple skill docs — reference both tools:
|
|
|
274
294
|
| Figma | REST API v1 / Webhooks V2 | Path-based versioning (`/v1/`, `/v2/`); `files:read` scope deprecated — use granular scopes |
|
|
275
295
|
| Slack | Web API (no versioned path) | Always check `ok` field (HTTP 200 even on errors); `files.upload` deprecated — use `files.getUploadURLExternal` |
|
|
276
296
|
| Stripe | `2026-02-25.clover` | Pin with `Stripe-Version` header; amounts in smallest currency unit (cents); never log raw card data |
|
|
297
|
+
| Notion | `2025-09-03` | `Notion-Version` header required on every request; share pages with integration before API access works |
|
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
# Notion Skill
|
|
2
|
+
|
|
3
|
+
> **Last validated:** 2026-03-02 | **API version:** `2025-09-03` (latest stable)
|
|
4
|
+
> **REST base URL:** `https://api.notion.com/v1/`
|
|
5
|
+
> **Assumed product:** Notion (cloud). The `Notion-Version: 2025-09-03` header is required on every request. This version introduced multi-source databases — see the Key concepts section for migration notes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What this skill enables
|
|
10
|
+
|
|
11
|
+
- Create and update pages (docs, notes, tasks, CRM records) programmatically inside any Notion workspace.
|
|
12
|
+
- Query databases with filters, sorts, and pagination — pull structured data from any Notion table, board, or calendar.
|
|
13
|
+
- Write rich page content: headings, paragraphs, lists, callouts, code blocks, and more via the Blocks API.
|
|
14
|
+
- Sync external data into Notion: push records from CRMs, issue trackers, or alert systems into Notion databases.
|
|
15
|
+
- Search across all workspace content (pages and databases) that the integration has access to.
|
|
16
|
+
- Build lightweight internal tools: project trackers, content calendars, CRM boards, on-call runbooks — without leaving Notion.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Best-fit use cases
|
|
21
|
+
|
|
22
|
+
| Use case | Why it matters | Primary objects | Typical trigger | Success criteria |
|
|
23
|
+
|----------|---------------|-----------------|-----------------|-----------------|
|
|
24
|
+
| Sync CRM deals to Notion pipeline | Sales visibility in Notion without manual entry | Database page, properties | HubSpot/Salesforce webhook on deal stage change | Notion database row created/updated with correct stage, owner, amount |
|
|
25
|
+
| Create incident runbook page | Auto-generate a structured runbook when PagerDuty alert fires | Page, Blocks | PagerDuty/Alertmanager webhook | Page created with headings, checklist, and links |
|
|
26
|
+
| Content calendar entry | Marketing team manages publishing schedule in Notion | Database page, date property | CMS or manual trigger | Page created with title, publish date, status, assignee |
|
|
27
|
+
| Bug/task tracker | Engineering team tracks work in Notion instead of or alongside Jira | Database page, select, relation | CI failure, Sentry alert | Page created in backlog with priority, assignee, reproduction steps |
|
|
28
|
+
| Weekly digest / standup | Summarise activity from GitHub/Jira/Slack into a Notion doc | Page, Blocks | Cron job (Monday morning) | Structured page with bullet points per team member |
|
|
29
|
+
| Knowledge base article | Auto-create docs from templates when a product feature ships | Page, Blocks | GitHub release webhook | Page created in wiki database with correct tags and cover |
|
|
30
|
+
| Meeting notes capture | Push structured meeting notes from an AI summary into Notion | Page, Blocks | Calendly/Zoom webhook on meeting end | Page in meetings database with attendees, action items, summary |
|
|
31
|
+
| Search-and-update pipeline | Find pages by title/tag and bulk-update a property | Search, PATCH page | Scheduled job | All matching pages updated; `last_edited_time` advances |
|
|
32
|
+
| Onboarding checklist | Create a personal checklist page for each new hire | Page, to_do blocks | HRIS onboarding event | Page with pre-checked and unchecked tasks, assigned to new hire |
|
|
33
|
+
| Incremental data sync | Pull all Notion pages modified since last run into a data warehouse | Database query, `last_edited_time` filter | Scheduled ETL job | Every modified page captured; no duplicates |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Key concepts & data model
|
|
38
|
+
|
|
39
|
+
### Core objects
|
|
40
|
+
|
|
41
|
+
| Object | Description | Identified by |
|
|
42
|
+
|--------|-------------|---------------|
|
|
43
|
+
| **Page** | A document in Notion; also functions as a row in a database | UUIDv4 `id`, e.g. `"a1b2c3d4-..."` |
|
|
44
|
+
| **Database** | A collection of pages sharing a property schema; can be inline or full-page | UUIDv4 `id` |
|
|
45
|
+
| **Block** | A unit of page content (paragraph, heading, list item, image, etc.) | UUIDv4 `id` |
|
|
46
|
+
| **User** | A workspace member or integration bot | UUIDv4 `id` |
|
|
47
|
+
| **Comment** | A discussion thread on a page or block | UUIDv4 `id` |
|
|
48
|
+
|
|
49
|
+
All IDs are UUIDv4 strings. Notion returns them with hyphens: `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"`. When passing IDs in URL paths you can use either format (with or without hyphens).
|
|
50
|
+
|
|
51
|
+
### Page vs database row
|
|
52
|
+
|
|
53
|
+
In Notion, a "row" in a database is just a Page whose `parent` is the database. Create rows by creating pages with `"parent": {"database_id": "..."}`. Every Page has a `properties` object whose shape is defined by the parent database's schema.
|
|
54
|
+
|
|
55
|
+
### Parent types
|
|
56
|
+
|
|
57
|
+
| `parent.type` | Meaning |
|
|
58
|
+
|---------------|---------|
|
|
59
|
+
| `"database_id"` | Page is a row in a database |
|
|
60
|
+
| `"page_id"` | Page is a sub-page of another page |
|
|
61
|
+
| `"workspace"` | Top-level page in the workspace |
|
|
62
|
+
| `"block_id"` | Page is embedded inside a block |
|
|
63
|
+
|
|
64
|
+
### Version 2025-09-03 — multi-source databases
|
|
65
|
+
|
|
66
|
+
Version `2025-09-03` introduced **multi-source databases** (databases that aggregate rows from multiple linked data sources). For single-source databases (the vast majority), nothing changes. If your database uses multiple data sources, creating pages requires a `data_source_id` in the parent object:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
"parent": { "type": "data_source_id", "data_source_id": "the-source-uuid" }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
For all standard databases, continue using `"database_id"`. The query endpoint `POST /v1/databases/{id}/query` continues to work unchanged.
|
|
73
|
+
|
|
74
|
+
### Property types
|
|
75
|
+
|
|
76
|
+
| Type | Writable? | Notes |
|
|
77
|
+
|------|-----------|-------|
|
|
78
|
+
| `title` | ✅ | Required on every database page; every database has exactly one |
|
|
79
|
+
| `rich_text` | ✅ | Up to 2,000 characters per value |
|
|
80
|
+
| `number` | ✅ | Integer or float |
|
|
81
|
+
| `select` | ✅ | Single option; pass `{"name": "..."}` (creates option if new) |
|
|
82
|
+
| `multi_select` | ✅ | Array of `{"name": "..."}` objects; max 100 options |
|
|
83
|
+
| `date` | ✅ | `{"start": "YYYY-MM-DD", "end": null}` or with time zone |
|
|
84
|
+
| `people` | ✅ | Array of `{"object": "user", "id": "..."}` |
|
|
85
|
+
| `files` | ✅ | External URLs only via API; Notion-hosted files read-only |
|
|
86
|
+
| `checkbox` | ✅ | Boolean |
|
|
87
|
+
| `url` | ✅ | String URL |
|
|
88
|
+
| `email` | ✅ | String |
|
|
89
|
+
| `phone_number` | ✅ | String |
|
|
90
|
+
| `relation` | ✅ | Array of `{"id": "page-uuid"}` — links to pages in related database |
|
|
91
|
+
| `formula` | ❌ | Read-only; computed by Notion |
|
|
92
|
+
| `rollup` | ❌ | Read-only; aggregated from relations |
|
|
93
|
+
| `created_time` | ❌ | Read-only |
|
|
94
|
+
| `created_by` | ❌ | Read-only |
|
|
95
|
+
| `last_edited_time` | ❌ | Read-only |
|
|
96
|
+
| `last_edited_by` | ❌ | Read-only |
|
|
97
|
+
|
|
98
|
+
### Rich text object
|
|
99
|
+
|
|
100
|
+
Used inside `title`, `rich_text`, block content, and more:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"type": "text",
|
|
105
|
+
"text": {
|
|
106
|
+
"content": "Hello, world",
|
|
107
|
+
"link": null
|
|
108
|
+
},
|
|
109
|
+
"annotations": {
|
|
110
|
+
"bold": false,
|
|
111
|
+
"italic": false,
|
|
112
|
+
"strikethrough": false,
|
|
113
|
+
"underline": false,
|
|
114
|
+
"code": false,
|
|
115
|
+
"color": "default"
|
|
116
|
+
},
|
|
117
|
+
"plain_text": "Hello, world"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When **writing** rich text, only `text.content` (and optionally `text.link.url` and `annotations`) are required. `plain_text` is ignored on writes.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Authentication & permissions
|
|
126
|
+
|
|
127
|
+
### Integration types
|
|
128
|
+
|
|
129
|
+
| Type | Token prefix | Use |
|
|
130
|
+
|------|-------------|-----|
|
|
131
|
+
| **Internal integration** | `secret_` | Single workspace; simplest setup; no OAuth needed |
|
|
132
|
+
| **Public integration (OAuth)** | `ntn_` | Multi-workspace; requires OAuth 2.0 flow |
|
|
133
|
+
|
|
134
|
+
### Internal integration setup
|
|
135
|
+
|
|
136
|
+
1. Go to **notion.so/my-integrations** → New integration → fill in name and select workspace.
|
|
137
|
+
2. Copy the **Internal Integration Secret** (`secret_...`).
|
|
138
|
+
3. **Share the page or database** with the integration: open the page in Notion → … menu → Connections → add your integration. Without this step every API call returns `object_not_found`.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
curl https://api.notion.com/v1/users/me \
|
|
142
|
+
-H "Authorization: Bearer secret_abc123..." \
|
|
143
|
+
-H "Notion-Version: 2025-09-03"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Integration capabilities
|
|
147
|
+
|
|
148
|
+
When creating an integration, select only the capabilities you need:
|
|
149
|
+
|
|
150
|
+
| Capability | What it allows |
|
|
151
|
+
|------------|---------------|
|
|
152
|
+
| Read content | GET pages, databases, blocks, comments |
|
|
153
|
+
| Update content | PATCH pages and blocks |
|
|
154
|
+
| Insert content | POST pages, blocks, comments |
|
|
155
|
+
| Read user information (no email) | GET /v1/users (names and avatars only) |
|
|
156
|
+
| Read user email addresses | GET /v1/users (includes email — restricted capability) |
|
|
157
|
+
|
|
158
|
+
### OAuth 2.0 (public integrations)
|
|
159
|
+
|
|
160
|
+
Used when your app accesses multiple users' workspaces:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
1. Redirect user → https://api.notion.com/v1/oauth/authorize
|
|
164
|
+
?client_id=<your-client-id>
|
|
165
|
+
&response_type=code
|
|
166
|
+
&owner=user
|
|
167
|
+
&redirect_uri=<your-callback-url>
|
|
168
|
+
|
|
169
|
+
2. User approves → Notion redirects back with ?code=<auth-code>
|
|
170
|
+
|
|
171
|
+
3. Exchange code for token:
|
|
172
|
+
POST https://api.notion.com/v1/oauth/token
|
|
173
|
+
Authorization: Basic base64(client_id:client_secret)
|
|
174
|
+
Content-Type: application/json
|
|
175
|
+
{"grant_type": "authorization_code", "code": "...", "redirect_uri": "..."}
|
|
176
|
+
|
|
177
|
+
4. Response: {"access_token": "ntn_...", "workspace_id": "...", "workspace_name": "...", "bot_id": "..."}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Use `access_token` as the Bearer token for all subsequent requests on behalf of that workspace.
|
|
181
|
+
|
|
182
|
+
### Required headers on every request
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
Authorization: Bearer <token>
|
|
186
|
+
Notion-Version: 2025-09-03
|
|
187
|
+
Content-Type: application/json (for POST/PATCH)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Common workflows (recipes)
|
|
193
|
+
|
|
194
|
+
### 1. Create a database row (page with properties)
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
curl -X POST https://api.notion.com/v1/pages \
|
|
198
|
+
-H "Authorization: Bearer secret_..." \
|
|
199
|
+
-H "Notion-Version: 2025-09-03" \
|
|
200
|
+
-H "Content-Type: application/json" \
|
|
201
|
+
-d '{
|
|
202
|
+
"parent": { "database_id": "your-database-uuid" },
|
|
203
|
+
"properties": {
|
|
204
|
+
"Name": {
|
|
205
|
+
"title": [{ "text": { "content": "Fix login bug" } }]
|
|
206
|
+
},
|
|
207
|
+
"Status": {
|
|
208
|
+
"select": { "name": "In Progress" }
|
|
209
|
+
},
|
|
210
|
+
"Priority": {
|
|
211
|
+
"select": { "name": "High" }
|
|
212
|
+
},
|
|
213
|
+
"Due Date": {
|
|
214
|
+
"date": { "start": "2026-03-15" }
|
|
215
|
+
},
|
|
216
|
+
"Assignee": {
|
|
217
|
+
"people": [{ "object": "user", "id": "user-uuid-here" }]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Response** (key fields):
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"object": "page",
|
|
228
|
+
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
229
|
+
"url": "https://www.notion.so/Fix-login-bug-a1b2c3d4e5f6...",
|
|
230
|
+
"properties": { ... },
|
|
231
|
+
"created_time": "2026-03-02T10:00:00.000Z",
|
|
232
|
+
"last_edited_time": "2026-03-02T10:00:00.000Z"
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
### 2. Query a database with filters and sorts
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
import requests
|
|
242
|
+
|
|
243
|
+
NOTION_TOKEN = "secret_..."
|
|
244
|
+
DATABASE_ID = "your-database-uuid"
|
|
245
|
+
|
|
246
|
+
headers = {
|
|
247
|
+
"Authorization": f"Bearer {NOTION_TOKEN}",
|
|
248
|
+
"Notion-Version": "2025-09-03",
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def query_database(filters=None, sorts=None):
|
|
253
|
+
pages = []
|
|
254
|
+
body = {"page_size": 100}
|
|
255
|
+
if filters:
|
|
256
|
+
body["filter"] = filters
|
|
257
|
+
if sorts:
|
|
258
|
+
body["sorts"] = sorts
|
|
259
|
+
|
|
260
|
+
while True:
|
|
261
|
+
resp = requests.post(
|
|
262
|
+
f"https://api.notion.com/v1/databases/{DATABASE_ID}/query",
|
|
263
|
+
headers=headers,
|
|
264
|
+
json=body,
|
|
265
|
+
)
|
|
266
|
+
resp.raise_for_status()
|
|
267
|
+
data = resp.json()
|
|
268
|
+
pages.extend(data["results"])
|
|
269
|
+
if not data["has_more"]:
|
|
270
|
+
break
|
|
271
|
+
body["start_cursor"] = data["next_cursor"]
|
|
272
|
+
return pages
|
|
273
|
+
|
|
274
|
+
# Example: all high-priority open tasks due this week
|
|
275
|
+
results = query_database(
|
|
276
|
+
filters={
|
|
277
|
+
"and": [
|
|
278
|
+
{"property": "Status", "select": {"equals": "In Progress"}},
|
|
279
|
+
{"property": "Priority", "select": {"equals": "High"}},
|
|
280
|
+
{"property": "Due Date", "date": {"next_week": {}}},
|
|
281
|
+
]
|
|
282
|
+
},
|
|
283
|
+
sorts=[{"property": "Due Date", "direction": "ascending"}],
|
|
284
|
+
)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
### 3. Update page properties
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
curl -X PATCH https://api.notion.com/v1/pages/page-uuid \
|
|
293
|
+
-H "Authorization: Bearer secret_..." \
|
|
294
|
+
-H "Notion-Version: 2025-09-03" \
|
|
295
|
+
-H "Content-Type: application/json" \
|
|
296
|
+
-d '{
|
|
297
|
+
"properties": {
|
|
298
|
+
"Status": { "select": { "name": "Done" } },
|
|
299
|
+
"Due Date": { "date": { "start": "2026-03-20" } }
|
|
300
|
+
}
|
|
301
|
+
}'
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
To archive (move to trash) a page:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
curl -X PATCH https://api.notion.com/v1/pages/page-uuid \
|
|
308
|
+
-H "Authorization: Bearer secret_..." \
|
|
309
|
+
-H "Notion-Version: 2025-09-03" \
|
|
310
|
+
-H "Content-Type: application/json" \
|
|
311
|
+
-d '{ "in_trash": true }'
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
### 4. Create a page with rich content (blocks)
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
blocks = [
|
|
320
|
+
{
|
|
321
|
+
"type": "heading_1",
|
|
322
|
+
"heading_1": {
|
|
323
|
+
"rich_text": [{"type": "text", "text": {"content": "Incident Runbook"}}]
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"type": "callout",
|
|
328
|
+
"callout": {
|
|
329
|
+
"rich_text": [{"type": "text", "text": {"content": "P1 — all hands"}}],
|
|
330
|
+
"icon": {"emoji": "🚨"},
|
|
331
|
+
"color": "red_background",
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
"type": "heading_2",
|
|
336
|
+
"heading_2": {
|
|
337
|
+
"rich_text": [{"type": "text", "text": {"content": "Checklist"}}]
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
"type": "to_do",
|
|
342
|
+
"to_do": {
|
|
343
|
+
"rich_text": [{"type": "text", "text": {"content": "Acknowledge alert in PagerDuty"}}],
|
|
344
|
+
"checked": False,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"type": "to_do",
|
|
349
|
+
"to_do": {
|
|
350
|
+
"rich_text": [{"type": "text", "text": {"content": "Notify #incidents Slack channel"}}],
|
|
351
|
+
"checked": False,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
"type": "code",
|
|
356
|
+
"code": {
|
|
357
|
+
"rich_text": [{"type": "text", "text": {"content": "kubectl get pods -n production"}}],
|
|
358
|
+
"language": "shell",
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
response = requests.post(
|
|
364
|
+
"https://api.notion.com/v1/pages",
|
|
365
|
+
headers=headers,
|
|
366
|
+
json={
|
|
367
|
+
"parent": {"page_id": "parent-page-uuid"},
|
|
368
|
+
"properties": {
|
|
369
|
+
"title": [{"text": {"content": "Incident: DB latency spike 2026-03-02"}}]
|
|
370
|
+
},
|
|
371
|
+
"children": blocks,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
page = response.json()
|
|
375
|
+
print(page["url"])
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Limits:** Max 100 blocks per `children` array in a single request. For longer pages, create the page first then append in batches (Recipe #5).
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
### 5. Append blocks to an existing page
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
def append_blocks(page_id: str, blocks: list, batch_size: int = 100) -> None:
|
|
386
|
+
"""Append blocks in batches of up to 100."""
|
|
387
|
+
for i in range(0, len(blocks), batch_size):
|
|
388
|
+
batch = blocks[i : i + batch_size]
|
|
389
|
+
resp = requests.patch(
|
|
390
|
+
f"https://api.notion.com/v1/blocks/{page_id}/children",
|
|
391
|
+
headers=headers,
|
|
392
|
+
json={"children": batch},
|
|
393
|
+
)
|
|
394
|
+
resp.raise_for_status()
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Use `"after": "block-uuid"` in the request body to insert at a specific position instead of at the end.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
### 6. Read all blocks from a page (recursive)
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
def get_all_blocks(block_id: str) -> list:
|
|
405
|
+
"""Fetch all blocks recursively, handling pagination and nesting."""
|
|
406
|
+
blocks = []
|
|
407
|
+
params = {"page_size": 100}
|
|
408
|
+
while True:
|
|
409
|
+
resp = requests.get(
|
|
410
|
+
f"https://api.notion.com/v1/blocks/{block_id}/children",
|
|
411
|
+
headers=headers,
|
|
412
|
+
params=params,
|
|
413
|
+
)
|
|
414
|
+
resp.raise_for_status()
|
|
415
|
+
data = resp.json()
|
|
416
|
+
for block in data["results"]:
|
|
417
|
+
blocks.append(block)
|
|
418
|
+
if block.get("has_children"):
|
|
419
|
+
block["children"] = get_all_blocks(block["id"])
|
|
420
|
+
if not data["has_more"]:
|
|
421
|
+
break
|
|
422
|
+
params["start_cursor"] = data["next_cursor"]
|
|
423
|
+
return blocks
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
**Note:** Notion does NOT return nested block content automatically — you must recurse for every block with `has_children: true`.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
### 7. Search for pages and databases
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
def search_notion(query: str, filter_type: str = None) -> list:
|
|
434
|
+
"""
|
|
435
|
+
filter_type: "page" | "database" | None (returns both)
|
|
436
|
+
Searches titles only — does not full-text search page content.
|
|
437
|
+
"""
|
|
438
|
+
body = {"query": query, "page_size": 100}
|
|
439
|
+
if filter_type:
|
|
440
|
+
body["filter"] = {"value": filter_type, "property": "object"}
|
|
441
|
+
|
|
442
|
+
results = []
|
|
443
|
+
while True:
|
|
444
|
+
resp = requests.post(
|
|
445
|
+
"https://api.notion.com/v1/search",
|
|
446
|
+
headers=headers,
|
|
447
|
+
json=body,
|
|
448
|
+
)
|
|
449
|
+
resp.raise_for_status()
|
|
450
|
+
data = resp.json()
|
|
451
|
+
results.extend(data["results"])
|
|
452
|
+
if not data["has_more"]:
|
|
453
|
+
break
|
|
454
|
+
body["start_cursor"] = data["next_cursor"]
|
|
455
|
+
return results
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Important:** Search only covers pages and databases **shared with your integration**. Content inside pages (block text) is not indexed by the search endpoint.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
462
|
+
### 8. Incremental sync — pages modified since last run
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
from datetime import datetime, timezone
|
|
466
|
+
|
|
467
|
+
def get_modified_pages(database_id: str, since: datetime) -> list:
|
|
468
|
+
"""Fetch all database rows edited after `since`."""
|
|
469
|
+
return query_database(
|
|
470
|
+
filters={
|
|
471
|
+
"timestamp": "last_edited_time",
|
|
472
|
+
"last_edited_time": {
|
|
473
|
+
"after": since.isoformat().replace("+00:00", "Z")
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
sorts=[{"timestamp": "last_edited_time", "direction": "ascending"}],
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Example — pull everything modified in the last hour
|
|
480
|
+
since = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
|
481
|
+
pages = get_modified_pages("your-database-uuid", since)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
### 9. List workspace users
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
curl https://api.notion.com/v1/users \
|
|
490
|
+
-H "Authorization: Bearer secret_..." \
|
|
491
|
+
-H "Notion-Version: 2025-09-03"
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
Response includes `type: "person"` (workspace members) and `type: "bot"` (integrations). The `person` type includes `person.email` only if your integration has the **Read user email addresses** capability.
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
### 10. Retrieve a page and read its properties
|
|
499
|
+
|
|
500
|
+
```python
|
|
501
|
+
resp = requests.get(
|
|
502
|
+
f"https://api.notion.com/v1/pages/{page_id}",
|
|
503
|
+
headers=headers,
|
|
504
|
+
)
|
|
505
|
+
page = resp.json()
|
|
506
|
+
|
|
507
|
+
# Extract title (works for any database page)
|
|
508
|
+
title_prop = next(
|
|
509
|
+
v for v in page["properties"].values() if v["type"] == "title"
|
|
510
|
+
)
|
|
511
|
+
title = "".join(rt["plain_text"] for rt in title_prop["title"])
|
|
512
|
+
|
|
513
|
+
# Read a select property
|
|
514
|
+
status = page["properties"].get("Status", {}).get("select", {}).get("name")
|
|
515
|
+
|
|
516
|
+
# Read a date property
|
|
517
|
+
due_date = page["properties"].get("Due Date", {}).get("date", {}).get("start")
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## Query patterns & filtering
|
|
523
|
+
|
|
524
|
+
### Filter structure
|
|
525
|
+
|
|
526
|
+
Filters are composed of **property filters** (test a specific property) combined with `"and"` / `"or"` arrays:
|
|
527
|
+
|
|
528
|
+
```json
|
|
529
|
+
{
|
|
530
|
+
"filter": {
|
|
531
|
+
"and": [
|
|
532
|
+
{
|
|
533
|
+
"property": "Status",
|
|
534
|
+
"select": { "equals": "In Progress" }
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
"or": [
|
|
538
|
+
{ "property": "Priority", "select": { "equals": "High" } },
|
|
539
|
+
{ "property": "Priority", "select": { "equals": "Critical" } }
|
|
540
|
+
]
|
|
541
|
+
}
|
|
542
|
+
]
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### Property filter operators by type
|
|
548
|
+
|
|
549
|
+
| Property type | Operators |
|
|
550
|
+
|---------------|-----------|
|
|
551
|
+
| `rich_text` | `equals`, `does_not_equal`, `contains`, `does_not_contain`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty` |
|
|
552
|
+
| `number` | `equals`, `does_not_equal`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to`, `is_empty`, `is_not_empty` |
|
|
553
|
+
| `select` | `equals`, `does_not_equal`, `is_empty`, `is_not_empty` |
|
|
554
|
+
| `multi_select` | `contains`, `does_not_contain`, `is_empty`, `is_not_empty` |
|
|
555
|
+
| `date` | `equals`, `before`, `after`, `on_or_before`, `on_or_after`, `is_empty`, `is_not_empty`, `past_week`, `past_month`, `past_year`, `next_week`, `next_month`, `next_year` |
|
|
556
|
+
| `checkbox` | `equals` (true/false) |
|
|
557
|
+
| `people` | `contains`, `does_not_contain`, `is_empty`, `is_not_empty` |
|
|
558
|
+
| `relation` | `contains`, `does_not_contain`, `is_empty`, `is_not_empty` |
|
|
559
|
+
| `files` | `is_empty`, `is_not_empty` |
|
|
560
|
+
|
|
561
|
+
### Timestamp filters (not a property — built-in page timestamps)
|
|
562
|
+
|
|
563
|
+
```json
|
|
564
|
+
{
|
|
565
|
+
"filter": {
|
|
566
|
+
"timestamp": "last_edited_time",
|
|
567
|
+
"last_edited_time": { "after": "2026-03-01T00:00:00Z" }
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Use `"timestamp": "created_time"` or `"last_edited_time"`.
|
|
573
|
+
|
|
574
|
+
### Sorts
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{
|
|
578
|
+
"sorts": [
|
|
579
|
+
{ "property": "Due Date", "direction": "ascending" },
|
|
580
|
+
{ "property": "Priority", "direction": "descending" }
|
|
581
|
+
]
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Sort by timestamp: `{ "timestamp": "last_edited_time", "direction": "descending" }`.
|
|
586
|
+
|
|
587
|
+
### Pagination
|
|
588
|
+
|
|
589
|
+
| Parameter | Default | Max | Notes |
|
|
590
|
+
|-----------|---------|-----|-------|
|
|
591
|
+
| `page_size` | 100 | 100 | Maximum is 100 for database queries and block children |
|
|
592
|
+
| `start_cursor` | — | — | Value of `next_cursor` from the previous response |
|
|
593
|
+
| `has_more` | — | — | `false` when there are no more pages |
|
|
594
|
+
|
|
595
|
+
Always paginate using `start_cursor` + `has_more` — do not assume 100 results means there are more.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## Reliability: rate limits, retries, idempotency
|
|
600
|
+
|
|
601
|
+
### Rate limits
|
|
602
|
+
|
|
603
|
+
| Limit | Value | Notes |
|
|
604
|
+
|-------|-------|-------|
|
|
605
|
+
| Global | **3 req/sec average** | Burst above this is allowed; sustained throughput is ~3/sec |
|
|
606
|
+
| Response on limit | HTTP `429`, code `"rate_limited"` | |
|
|
607
|
+
| Backoff header | `Retry-After` (integer seconds) | Always honour this value |
|
|
608
|
+
|
|
609
|
+
There is no separate rate limit per endpoint — it's a single workspace-level budget.
|
|
610
|
+
|
|
611
|
+
### Retry with exponential backoff
|
|
612
|
+
|
|
613
|
+
```python
|
|
614
|
+
import time
|
|
615
|
+
import requests
|
|
616
|
+
|
|
617
|
+
def notion_request(method: str, url: str, **kwargs) -> dict:
|
|
618
|
+
headers = kwargs.pop("headers", {})
|
|
619
|
+
headers.update({
|
|
620
|
+
"Authorization": f"Bearer {NOTION_TOKEN}",
|
|
621
|
+
"Notion-Version": "2025-09-03",
|
|
622
|
+
"Content-Type": "application/json",
|
|
623
|
+
})
|
|
624
|
+
for attempt in range(5):
|
|
625
|
+
resp = requests.request(method, url, headers=headers, **kwargs)
|
|
626
|
+
if resp.status_code == 429:
|
|
627
|
+
wait = int(resp.headers.get("Retry-After", 2 ** attempt))
|
|
628
|
+
time.sleep(wait)
|
|
629
|
+
continue
|
|
630
|
+
if resp.status_code in (500, 502, 503, 504):
|
|
631
|
+
time.sleep(2 ** attempt)
|
|
632
|
+
continue
|
|
633
|
+
resp.raise_for_status()
|
|
634
|
+
return resp.json()
|
|
635
|
+
raise RuntimeError(f"Failed after 5 attempts: {url}")
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
### Request size limits
|
|
639
|
+
|
|
640
|
+
| Limit | Value |
|
|
641
|
+
|-------|-------|
|
|
642
|
+
| Blocks per request (`children` array) | 100 |
|
|
643
|
+
| Total request payload | 500 KB |
|
|
644
|
+
| Rich text field | 2,000 characters |
|
|
645
|
+
| URL / email / phone_number fields | 2,000 / 200 / 200 characters |
|
|
646
|
+
| Multi-select options | 100 |
|
|
647
|
+
| Relation links | 100 per property |
|
|
648
|
+
| People mentions per property | 100 |
|
|
649
|
+
|
|
650
|
+
### Idempotency
|
|
651
|
+
|
|
652
|
+
The Notion API does **not** provide an idempotency key mechanism. To avoid duplicate pages, query the database first (filter on a unique external ID stored in a `rich_text` or `url` property) before creating:
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
def upsert_page(database_id: str, external_id: str, properties: dict) -> dict:
|
|
656
|
+
existing = query_database(
|
|
657
|
+
filters={"property": "External ID", "rich_text": {"equals": external_id}}
|
|
658
|
+
)
|
|
659
|
+
if existing:
|
|
660
|
+
page_id = existing[0]["id"]
|
|
661
|
+
return notion_request("PATCH", f"https://api.notion.com/v1/pages/{page_id}", json={"properties": properties})
|
|
662
|
+
return notion_request("POST", "https://api.notion.com/v1/pages", json={
|
|
663
|
+
"parent": {"database_id": database_id},
|
|
664
|
+
"properties": {**properties, "External ID": {"rich_text": [{"text": {"content": external_id}}]}},
|
|
665
|
+
})
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## Error handling & troubleshooting
|
|
671
|
+
|
|
672
|
+
### Error object structure
|
|
673
|
+
|
|
674
|
+
```json
|
|
675
|
+
{
|
|
676
|
+
"object": "error",
|
|
677
|
+
"status": 404,
|
|
678
|
+
"code": "object_not_found",
|
|
679
|
+
"message": "Could not find database with ID: a1b2c3d4-..."
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
### Status → code → action
|
|
684
|
+
|
|
685
|
+
| Status | Code | Meaning | Fix |
|
|
686
|
+
|--------|------|---------|-----|
|
|
687
|
+
| 400 | `invalid_json` | Request body is not valid JSON | Check JSON encoding; ensure `Content-Type: application/json` |
|
|
688
|
+
| 400 | `invalid_request_url` | URL is malformed | Verify endpoint path and UUID format |
|
|
689
|
+
| 400 | `invalid_request` | Operation not supported | Read the API docs for this endpoint |
|
|
690
|
+
| 400 | `validation_error` | Parameter shape is wrong | Check `message` for the specific field; verify property type JSON |
|
|
691
|
+
| 400 | `missing_version` | `Notion-Version` header absent | Add `Notion-Version: 2025-09-03` to all requests |
|
|
692
|
+
| 401 | `unauthorized` | Token invalid or expired | Verify token prefix (`secret_` or `ntn_`); check it wasn't revoked |
|
|
693
|
+
| 403 | `restricted_resource` | Integration lacks capability | Add the required capability in notion.so/my-integrations |
|
|
694
|
+
| 404 | `object_not_found` | Page/DB not found or not shared | Share the page/database with your integration in Notion UI |
|
|
695
|
+
| 409 | `conflict_error` | Concurrent write collision | Retry the request; consider optimistic locking with `last_edited_time` |
|
|
696
|
+
| 429 | `rate_limited` | Too many requests | Wait `Retry-After` seconds; use exponential backoff |
|
|
697
|
+
| 500 | `internal_server_error` | Notion server error | Retry with backoff; check https://status.notion.so |
|
|
698
|
+
| 503 | `service_unavailable` | Notion offline or request timed out (60s limit) | Retry; break up large block payloads |
|
|
699
|
+
| 503 | `database_connection_unavailable` | Notion DB unreachable | Retry with backoff |
|
|
700
|
+
| 504 | `gateway_timeout` | Request processing timed out | Reduce payload size; retry |
|
|
701
|
+
|
|
702
|
+
### Common issues
|
|
703
|
+
|
|
704
|
+
| Symptom | Cause | Fix |
|
|
705
|
+
|---------|-------|-----|
|
|
706
|
+
| `object_not_found` on a valid ID | Integration not shared with the page | Open page in Notion → … → Connections → add integration |
|
|
707
|
+
| `restricted_resource` on read | Integration missing "Read content" capability | Edit integration at notion.so/my-integrations |
|
|
708
|
+
| `validation_error` on property write | Wrong JSON structure for property type | Copy exact write structure from Key concepts section |
|
|
709
|
+
| Empty `results` array from query | Database not shared, or filter matches nothing | Check sharing first; test with no filter |
|
|
710
|
+
| `missing_version` on every request | `Notion-Version` header not set | Add header; if using a framework, set it as a default |
|
|
711
|
+
| Blocks truncated | Response only returns first level | Recurse on `has_children: true` blocks |
|
|
712
|
+
| New page missing in search | Search indexes with a delay | Wait a few seconds; query the database directly for freshness |
|
|
713
|
+
| 503 on large page create | Request payload > 500 KB or > 100 blocks | Split into create + append batches |
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
## Security & compliance
|
|
718
|
+
|
|
719
|
+
### Token storage
|
|
720
|
+
|
|
721
|
+
- Store integration tokens in environment variables or a secrets manager — never hardcode in source.
|
|
722
|
+
- Internal integration secrets (`secret_...`) have no expiry — rotate them manually in notion.so/my-integrations if compromised.
|
|
723
|
+
- OAuth access tokens (`ntn_...`) may expire depending on workspace settings — implement refresh logic if building a public integration.
|
|
724
|
+
|
|
725
|
+
### Least-privilege capabilities
|
|
726
|
+
|
|
727
|
+
Only enable the capabilities your integration actually needs:
|
|
728
|
+
|
|
729
|
+
| Use case | Required capabilities |
|
|
730
|
+
|----------|----------------------|
|
|
731
|
+
| Read-only reporting / sync | Read content only |
|
|
732
|
+
| Creating pages / blocks | Read content + Insert content |
|
|
733
|
+
| Updating existing pages | Read content + Update content |
|
|
734
|
+
| User lookup (names only) | Read user information (no email) |
|
|
735
|
+
| User lookup with emails | Read user email addresses |
|
|
736
|
+
|
|
737
|
+
### Data access scope
|
|
738
|
+
|
|
739
|
+
Notion integrations only access what is explicitly shared with them. A page or database must be shared with the integration before the API can see it — there is no "access all workspace content" mode for internal integrations. This is a security feature: scope access narrowly by sharing only the databases your automation needs.
|
|
740
|
+
|
|
741
|
+
### PII considerations
|
|
742
|
+
|
|
743
|
+
Notion databases often hold PII (names, emails, phone numbers). Ensure:
|
|
744
|
+
- API responses containing PII are not logged in plaintext.
|
|
745
|
+
- Access tokens are scoped to only the databases needed.
|
|
746
|
+
- Data written to Notion from external sources is sanitised (rich text supports no HTML injection risk, but verify URL fields).
|
|
747
|
+
|
|
748
|
+
### Audit trail
|
|
749
|
+
|
|
750
|
+
All integration activity is visible in **Notion → Settings → Connections** (workspace owners). Use meaningful integration names (e.g., "CRM Sync Bot") so admins can identify the source of changes.
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
## Testing checklist
|
|
755
|
+
|
|
756
|
+
### Setup for testing
|
|
757
|
+
|
|
758
|
+
1. Create a test workspace (free plan) or a test section in your workspace.
|
|
759
|
+
2. Create an internal integration at notion.so/my-integrations.
|
|
760
|
+
3. Create a test database and share it with the integration.
|
|
761
|
+
4. Store the integration token and database ID in environment variables.
|
|
762
|
+
|
|
763
|
+
### QA checklist
|
|
764
|
+
|
|
765
|
+
- [ ] `GET /v1/users/me` returns the bot user with correct name → confirms token is valid
|
|
766
|
+
- [ ] `POST /v1/pages` with a `title` property creates a row in the test database
|
|
767
|
+
- [ ] `POST /v1/databases/{id}/query` returns the newly created page
|
|
768
|
+
- [ ] `POST /v1/databases/{id}/query` with a `select` filter returns only matching pages
|
|
769
|
+
- [ ] `POST /v1/databases/{id}/query` with `page_size=1` returns `has_more: true`; cursor retrieves next page
|
|
770
|
+
- [ ] `PATCH /v1/pages/{id}` updates a `select` property; `GET` confirms new value
|
|
771
|
+
- [ ] `PATCH /v1/pages/{id}` with `"in_trash": true` removes page; subsequent `GET` returns archived page
|
|
772
|
+
- [ ] `PATCH /v1/blocks/{id}/children` appends a paragraph block; `GET /v1/blocks/{id}/children` returns it
|
|
773
|
+
- [ ] `POST /v1/search` with `query: "<page title>"` finds the test page
|
|
774
|
+
- [ ] Request sent without `Notion-Version` header returns `400` with code `missing_version`
|
|
775
|
+
- [ ] Request to a page not shared with integration returns `404` with code `object_not_found`
|
|
776
|
+
- [ ] Rapid requests (>3/sec) trigger `429`; `Retry-After` header is present; retry succeeds
|
|
777
|
+
- [ ] Payload with 101 blocks in `children` returns `validation_error`
|
|
778
|
+
- [ ] Upsert pattern: create, then call upsert again with same external ID → updates, does not duplicate
|
|
779
|
+
|
|
780
|
+
---
|
|
781
|
+
|
|
782
|
+
## Sources
|
|
783
|
+
|
|
784
|
+
- [Notion API Reference — Introduction](https://developers.notion.com/reference/intro)
|
|
785
|
+
- [Notion Versioning (current: 2025-09-03)](https://developers.notion.com/reference/versioning)
|
|
786
|
+
- [Upgrade Guide: 2025-09-03](https://developers.notion.com/docs/upgrade-guide-2025-09-03)
|
|
787
|
+
- [Authentication](https://developers.notion.com/reference/authentication)
|
|
788
|
+
- [Create a Page](https://developers.notion.com/reference/post-page)
|
|
789
|
+
- [Retrieve a Page](https://developers.notion.com/reference/retrieve-a-page)
|
|
790
|
+
- [Update Page Properties](https://developers.notion.com/reference/patch-page)
|
|
791
|
+
- [Query a Database](https://developers.notion.com/reference/post-database-query)
|
|
792
|
+
- [Working with Databases](https://developers.notion.com/docs/working-with-databases)
|
|
793
|
+
- [Page Property Values](https://developers.notion.com/reference/page-property-values)
|
|
794
|
+
- [Block Object](https://developers.notion.com/reference/block)
|
|
795
|
+
- [Working with Page Content](https://developers.notion.com/docs/working-with-page-content)
|
|
796
|
+
- [Append Block Children](https://developers.notion.com/reference/patch-block-children)
|
|
797
|
+
- [Search](https://developers.notion.com/reference/post-search)
|
|
798
|
+
- [Request Limits (rate limits)](https://developers.notion.com/reference/request-limits)
|
|
799
|
+
- [Error Codes](https://developers.notion.com/reference/errors)
|
|
800
|
+
- [API Status](https://status.notion.so)
|