---
name: notion
description: "Notion API via curl: pages, databases, blocks, search."
version: 1.0.0
author: community
license: MIT
metadata:
  hermes:
    tags: [Notion, Productivity, Notes, Database, API]
    homepage: https://developers.notion.com
prerequisites:
  env_vars: [NOTION_API_KEY]
---

# Notion API

Use the Notion API via curl to create, read, update pages, databases (data sources), and blocks. No extra tools needed — just curl and a Notion API key.

## Prerequisites

1. Create an integration at https://notion.so/my-integrations
2. Copy the API key (starts with `ntn_` or `secret_`)
3. Store it in `~/.hermes/.env`:
   ```
   NOTION_API_KEY=ntn_your_key_here
   ```
4. **Important:** Share target pages/databases with your integration in Notion (click "..." → "Connect to" → your integration name)

## Status

✅ **Configured and active.** The integration "Kiwi" is connected to workspace "Notion von Ahmed Zeyd Aytac". The API key is stored in `~/.hermes/.env`.

**Usage rule:** Hermes Agent should proactively use Notion for:
- Looking up project notes, ideas, and documentation
- Creating/searching pages when the user asks about stored information
- Using Notion as the primary knowledge base alongside local files
- Checking Notion BEFORE asking the user if they've already documented something

**Shell pattern** (always use `export` + `&&`, never inline `VAR=val cmd`):
```bash
export NOTION_API_KEY="$(grep NOTION /DATA/.hermes/.env | cut -d= -f2-)" && curl -s ... -H "Authorization: Bearer ${NOTION_API_KEY}" ...
```

## API Basics

All requests use this pattern:

```bash
curl -s -X GET "https://api.notion.com/v1/..." \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json"
```

The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API.

## Common Operations

### Search

```bash
curl -s -X POST "https://api.notion.com/v1/search" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json" \
  -d '{"query": "page title"}'
```

### Get Page

```bash
curl -s "https://api.notion.com/v1/pages/{page_id}" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03"
```

### Get Page Content (blocks)

```bash
curl -s "https://api.notion.com/v1/blocks/{page_id}/children" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03"
```

### Create Page in a Database

```bash
curl -s -X POST "https://api.notion.com/v1/pages" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": {"database_id": "xxx"},
    "properties": {
      "Name": {"title": [{"text": {"content": "New Item"}}]},
      "Status": {"select": {"name": "Todo"}}
    }
  }'
```

### Query a Database

```bash
curl -s -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json" \
  -d '{
    "filter": {"property": "Status", "select": {"equals": "Active"}},
    "sorts": [{"property": "Date", "direction": "descending"}]
  }'
```

### Create a Database

```bash
curl -s -X POST "https://api.notion.com/v1/data_sources" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json" \
  -d '{
    "parent": {"page_id": "xxx"},
    "title": [{"text": {"content": "My Database"}}],
    "properties": {
      "Name": {"title": {}},
      "Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
      "Date": {"date": {}}
    }
  }'
```

### Update Page Properties

```bash
curl -s -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json" \
  -d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
```

### Add Content to a Page

```bash
curl -s -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
  -H "Authorization: Bearer $NOTION_API_KEY" \
  -H "Notion-Version: 2025-09-03" \
  -H "Content-Type: application/json" \
  -d '{
    "children": [
      {"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello from Hermes!"}}]}}
    ]
  }'
```

## Property Types

Common property formats for database items:

- **Title:** `{"title": [{"text": {"content": "..."}}]}`
- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}`
- **Select:** `{"select": {"name": "Option"}}`
- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}`
- **Date:** `{"date": {"start": "2026-01-15", "end": "2026-01-16"}}`
- **Checkbox:** `{"checkbox": true}`
- **Number:** `{"number": 42}`
- **URL:** `{"url": "https://..."}`
- **Email:** `{"email": "user@example.com"}`
- **Relation:** `{"relation": [{"id": "page_id"}]}`

## Key Differences in API Version 2025-09-03

- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval
- **Two IDs:** Each database has both a `database_id` and a `data_source_id`
  - Use `database_id` when creating pages (`parent: {"database_id": "..."}`)
  - Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`)
- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id`

## Pitfalls

- **Unicode / Umlauts: always use proper characters, never ASCII-ify.** When writing German legal content, use `§`, `ä`, `ö`, `ü`, `ß` directly in Python strings. `json.dumps(ensure_ascii=False)` preserves them. Never use `Par`, `ss`, `ae`, `oe`, `ue` as replacements — they will pass validation but produce incorrect output. If blocks were created with ASCII-ified text, fix them by PATCHing individual blocks with the corrected `rich_text`. A single comprehensive `replace()` pass per block is cleaner than multiple targeted fixes.
- **`annotations` rejected on paragraph blocks in v2025-09-03.** The Notion API may reject `annotations` (e.g., `{"bold": true}`) at the `rich_text` level for paragraph blocks with `"body failed validation: body.children[N].paragraph.rich_text[0].text.annotations should be not present"`. Omit annotations on paragraph blocks; use them only on headings, quotes, and other block types that support them.
- **PATCH block: strip `icon` from response before reusing as payload.** When you GET a block and want to PATCH it, the response may include `"icon": null`. Notion rejects `"icon": null` in PATCH requests (`"body.paragraph.icon should be an object or 'undefined', instead was 'null'"`). Build a clean update payload containing ONLY the block type data (`{btype: {"rich_text": new_rt}}`), not the full block response.
- **Cross-version querying is often required.** Notion's v2025-09-03 renamed `databases` → `data_sources` and moved querying to `/v1/data_sources/{id}/query`. However, some databases (especially older ones or child databases embedded in pages) only respond to the v2022-06-28 `/v1/databases/{id}/query` endpoint. The v2025-09-03 `/v1/data_sources/` endpoint returns 404 "Could not find database" for these, while the same database under v2022-06-28 returns results normally. **Rule of thumb:** child databases (`child_database` block type on a page) and databases created before ~2024 almost always need the v2022-06-28 fallback. **Solution:** Try v2025-09-03 with `data_sources` endpoint first; if 404 or "Invalid request URL", fall back to v2022-06-28 with `databases` endpoint. Both return the same result shape — only the URL path and version header differ. Always test both versions before concluding a database is inaccessible. Pro-tip: use Python subprocess in the `execute_code` sandbox to systematically try both version/endpoint combinations — it's cleaner than chaining curl commands in bash.
- **Database IDs from URLs may differ from API-discoverable IDs.** The UUID in a Notion share URL (e.g., `1d577cd7920081d8…`) might not be the ID the API recognizes. Use `/v1/search` to find the database by title, then use the returned ID. The search endpoint returns the correct ID regardless of what the URL shows, and the same database can appear with different IDs depending on access path (inline in a page vs. standalone).
- **Child databases in pages need page sharing.** A database embedded as `child_database` block on a page requires that PAGE to be shared with the integration, not the database itself. If `/v1/search` finds the database but queries return 404, check that the parent page (not the database) is shared with your integration in the Notion UI.
- **`annotations` (bold, italic, etc.) are rejected on many block types in v2025-09-03.** The API returns validation_error: "annotations should be not present" for blocks like `paragraph`, `bulleted_list_item`, and `quote`. Only heading blocks (`heading_1`–`heading_3`) accept annotations. Keep rich_text payloads flat: `{"text": {"content": "..."}}` without an `annotations` key for body text blocks. If you need bold/italic in a paragraph, use inline markdown-ish workarounds like `**text**` or just rely on headings for emphasis.
- **`source .env` fails for one-shot curl commands.** Running `source ~/.hermes/.env && curl ... $NOTION_API_KEY` does NOT export the variable into the curl subprocess — it only sets it in the current shell scope. Use this pattern instead:
  ```bash
  export NOTION_API_KEY="ntn_..." && curl -s "https://api.notion.com/v1/..." \
    -H "Authorization: Bearer ${NOTION_API_KEY}" \
    -H "Notion-Version: 2025-09-03"
  ```
  Or inline: `NOTION_API_KEY=ntn_... curl -s ...`. The key must be passed directly to curl's environment, not sourced from a file.
- **`xargs` may not be available** on minimal systems (ZimaOS). Don't rely on `grep ... | xargs` to extract env vars. Use inline assignment instead.
- **Batch limit — 100 blocks per PATCH:** `PATCH /v1/blocks/{page_id}/children` accepts at most 100 blocks per request. For large content (e.g., hundreds of list items), split into batches: group blocks into arrays of ≤100, send each batch sequentially with a 0.3–0.5s delay between requests. Use Python `subprocess` + `json.dumps` for clean payload construction rather than chaining curl in bash.
- **401 Unauthorized with valid-looking key?** Try both `export KEY=...` AND switching `Notion-Version` header (e.g., `2022-06-28` vs `2025-09-03`). If the `/users/me` endpoint succeeds, the key is valid and the integration was created correctly — then verify pages/databases are shared with the integration in the Notion UI.
- **Search is literal, not semantic — use multiple query terms.** Notion's `/v1/search` matches page titles and property text literally, not semantically. A search for "Bewerbung" may return 0 results even when a page titled "Curriculum Vitae" exists. **Always run multiple searches with different terms** covering synonyms, languages, and related concepts (e.g., "Lebenslauf", "CV", "Resume", "Motivation", "Anschreiben", "Job", "Karriere"). Deduplicate by page ID. Use Python `execute_code` sandbox to loop over query terms cleanly rather than chaining curl in bash.

## Bulk Page Content

When pushing large structured datasets into a page (hundreds of blocks), see `references/bulk-page-inserts.md` for the batching pattern: group into ≤100-block batches, PATCH with 400ms spacing, `ensure_ascii=False` for umlauts.

## Budget Sankey Diagram

See `references/budget-sankey-from-notion.md` for the full workflow. The user has a monthly budget database (`1d577cd7920081d89ca2fcca76003d45`) queried with `v2022-06-28`. When asked to visualize it:

- Render a **clean Sankey diagram (Plotly)** — no social-media card chrome, no avatar, no footer
- Fixkosten: show every position individually
- Versicherungen: group all four into one node
- Taschengeld Zeyd: split into Ollama (19.33€), Anthropic (21.60€), Rest
- Colors: greens for income, oranges for Fixkosten, blue for Versicherungen, pink for Abos, purple for Investment
- Output as standalone HTML file and send via MEDIA

## Notes

- Page/database IDs are UUIDs (with or without dashes)
- Rate limit: ~3 requests/second average
- The API cannot set database view filters — that's UI-only
- Use `is_inline: true` when creating data sources to embed them in pages
- Add `-s` flag to curl to suppress progress bars (cleaner output for Hermes)
- Pipe output through `jq` for readable JSON: `... | jq '.results[0].properties'`

## Budget Dashboard (HTML)

When the user asks for a visual budget overview from Notion data, use the template at `templates/budget-dashboard.html`. It renders horizontal bar charts (income, expense categories, fixed costs) with colored bars and a summary section. Replace the `data` array with Notion query results and extend `colors` for any new categories.

**Notion → Dashboard workflow:**
1. Query the database (try v2022-06-28 first — most budget databases are older child databases)
2. Extract `Position` (title), `Art` (multi_select → first option name), `Betrag` (number)
3. Build the JavaScript `data` array: `{ position, art, betrag }`
4. Copy the template, replace the `data` array, save as `.html`
5. Write to `/tmp/` (execute_code sandbox has no `/media` access), copy to target via `terminal`
