clawskills-mcp 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An MCP (Model Context Protocol) server that exposes [ClawSkills](https://github.com/Shanksg/clawskills) API integration skill docs to AI agents.
4
4
 
5
- ClawSkills contains structured Markdown guides for 10+ SaaS APIs (Salesforce, HubSpot, GitHub, Jira, etc.) covering auth, rate limits, pagination, error handling, and recipes — everything an agent needs to make reliable API calls.
5
+ ClawSkills contains structured Markdown guides for 13 SaaS APIs (Salesforce, HubSpot, GitHub, Jira, Stripe, Notion, Slack, and more) covering auth, rate limits, pagination, error handling, and recipes — everything an agent needs to make reliable API calls.
6
6
 
7
7
  ---
8
8
 
@@ -101,16 +101,19 @@ Skills are bundled inside the npm package — no `SKILLS_DIR` configuration need
101
101
 
102
102
  ## Available skills
103
103
 
104
- - `asana` — Asana API
104
+ - `asana` — Asana REST API
105
105
  - `dynamics365` — Microsoft Dynamics 365
106
- - `figma` — Figma API
107
- - `github` — GitHub REST API
106
+ - `figma` — Figma REST API
107
+ - `github` — GitHub REST API + GraphQL
108
108
  - `hubspot` — HubSpot CRM API
109
109
  - `jira` — Jira Cloud REST API
110
- - `monday` — Monday.com API
110
+ - `monday` — Monday.com GraphQL API
111
+ - `notion` — Notion REST API (Pages, Databases, Blocks)
111
112
  - `salesforce` — Salesforce REST API
112
- - `servicenow` — ServiceNow REST API
113
- - `zendesk` — Zendesk REST API
113
+ - `servicenow` — ServiceNow Table API
114
+ - `slack` — Slack Web API + Events API
115
+ - `stripe` — Stripe Payments API (Billing, Connect)
116
+ - `zendesk` — Zendesk Support API
114
117
 
115
118
  ---
116
119
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawskills-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "MCP server exposing ClawSkills API integration skill docs to AI agents",
5
5
  "license": "MIT",
6
6
  "author": "ClawSkills Contributors",
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-01 (Stripe skill added) | See [ROADMAP.md](./ROADMAP.md) for versioning and governance details.*
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)