---
name: avizmarlon/notion-api
source: https://app.decimal.ai/s/avizmarlon-notion-api@1/SKILL.md
source_sha256: bf1285a877db
---

# Notion REST API — Direct wrapper for batch operations

When working with Notion programmatically, **use the REST API (not the MCP)** for any batch-shaped work. MCP implementations (like `notion-search`) typically have hard limitations (25-result caps, no real pagination) that make batch operations tedious. The REST API handles 100+ rows in seconds with auto-pagination.

---

## When to use REST API vs MCP

| Operation | Use |
|---|---|
| Read 1 page, update 1 page, search for a page | MCP OK |
| Add a comment, create a single inline note | MCP OK |
| Query an entire database (any size), with filters and pagination | **REST API** |
| Batch update N pages (rename, retag, restructure) | **REST API** |
| Audit/scan/inventory a database | **REST API** |
| Replicate page body blocks | **REST API** |
| Upload files/images programmatically | **REST API** (MCP cannot) |
| Create a new database programmatically | **REST API** |
| Move pages by re-creating under a new parent | **REST API** |
| Any operation touching ≥3 rows | **REST API** |

If you find yourself calling MCP twice for the same data, switch to the REST API.

---

## Python wrapper pattern

Create a thin self-contained wrapper around the Notion REST API. This wrapper should:

- Handle **authentication** via environment variable (e.g., `NOTION_API_KEY` or `NOTION_TOKEN`)
- Provide **auto-pagination** for list endpoints (use `start_cursor` / `has_more` loops)
- Include **retry logic** with exponential backoff for rate limits (HTTP 429) and transient errors (5xx)
- **Throttle writes** to respect Notion's published rate limit (~3 req/s)
- Expose **CRUD functions** for pages, databases, blocks, and comments
- Include **property builders** for clean schema construction (title, rich_text, select, date, etc.)
- Avoid external dependencies — use Python stdlib only (e.g., `urllib`, `json`, `time`)

### Canonical imports (your wrapper should export)

```python
# Core CRUD
get_page, create_page, update_page, archive_page
get_database, create_database, update_database

# Querying (query_db_all should auto-paginate)
query_database, query_db_all

# Blocks
get_block, list_block_children, list_block_children_all
append_block_children, update_block, delete_block

# Search and metadata
search, search_all, list_users_all, get_user, whoami
create_comment, list_comments_all

# File uploads (for image/attachment re-hosting)
file_upload_create, file_upload_send, file_upload_complete

# Property builders (clean payloads)
title_prop, rich_text_prop, select_prop, multi_select_prop
status_prop, number_prop, checkbox_prop, date_prop, url_prop
relation_prop, people_prop, files_prop

# Block builders
paragraph_block, heading_block, bulleted_list_item, numbered_list_item
to_do_block, code_block, divider_block, callout_block, quote_block

# Text helpers
title_text, rich_text_text

# Custom exceptions
NotionAPIError
```

---

## Authentication

Read the token from an environment variable (e.g., `NOTION_API_KEY` or `NOTION_TOKEN`). Allow per-call override via function parameter:

```python
import os
token = os.getenv("NOTION_API_KEY")
# or pass token="Bearer secret_..." to any function call
```

Store the token securely outside the codebase (environment, credential manager, or vault).

---

## Canonical patterns (copy-paste-modify)

### 1. Iterate every page in a database

```python
for page in query_db_all("DATABASE_ID"):
    # page is the full Notion page object
    # Access properties via page["properties"]
    print(page["id"])
```

### 2. Read text property values cleanly

```python
def get_plain_text(page, prop_name):
    """Extract plain text from title or rich_text property."""
    p = page["properties"].get(prop_name, {})
    if p.get("type") == "title":
        return "".join(t.get("plain_text", "") for t in p.get("title", []))
    return "".join(t.get("plain_text", "") for t in p.get("rich_text", []))
```

### 3. Batch update a property across many pages

```python
from notion_api import query_db_all, update_page, select_prop, rich_text_prop

for page in query_db_all("DATABASE_ID"):
    if get_plain_text(page, "Status") == "Stale":
        update_page(page["id"], {
            "Status": select_prop("Archived"),
            "Notes": rich_text_prop("Auto-archived"),
        })
```

The wrapper should throttle writes automatically (e.g., ~3 req/s).

### 4. Create a page with properties copied from another

```python
import copy

def clean_property(value):
    """Strip read-only fields (id, plain_text, href) so the payload is valid."""
    ptype = value["type"]
    if ptype in ("title", "rich_text"):
        items = []
        for it in value.get(ptype, []):
            it2 = copy.deepcopy(it)
            it2.pop("plain_text", None)
            it2.pop("href", None)
            items.append(it2)
        return {ptype: items}
    raise ValueError(f"Handle property type {ptype!r}")

src_page = get_page("SOURCE_PAGE_ID")
props = {n: clean_property(v) for n, v in src_page["properties"].items()}
new_page = create_page(parent={"database_id": "TARGET_DATABASE_ID"}, properties=props)
```

### 5. Replicate page body (blocks)

```python
import copy

src_blocks = list(list_block_children_all("SOURCE_PAGE_ID"))
clean = []

for b in src_blocks:
    if b["type"] == "image":
        # Images with signed S3 URLs must be re-uploaded (see pattern #6)
        continue
    
    btype = b["type"]
    payload = {"type": btype, btype: copy.deepcopy(b[btype])}
    
    # Strip plain_text and href from rich_text
    inner = payload[btype]
    if isinstance(inner, dict) and "rich_text" in inner:
        for rt in inner["rich_text"]:
            rt.pop("plain_text", None)
            rt.pop("href", None)
    
    clean.append(payload)

append_block_children("TARGET_PAGE_ID", clean)
```

### 6. Re-upload an image block (signed URLs expire in ~1 hour)

```python
import urllib.request

src_url = src_blocks[i]["image"]["file"]["url"]  # signed, expires soon
img_bytes = urllib.request.urlopen(
    urllib.request.Request(src_url, headers={"User-Agent": "Mozilla/5.0"})
).read()

fu = file_upload_create(mode="single_part", filename="image.png", content_type="image/png")
file_upload_send(fu["id"], img_bytes, filename="image.png", content_type="image/png")

append_block_children("TARGET_PAGE_ID", [{
    "type": "image",
    "image": {"type": "file_upload", "file_upload": {"id": fu["id"]}, "caption": []},
}])
```

### 7. Create a new database under a page

```python
new_db = create_database(
    parent={"type": "page_id", "page_id": "PARENT_PAGE_ID"},
    title=[{"type": "text", "text": {"content": "My Database"}}],
    properties={
        "Name": {"title": {}},
        "Notes": {"rich_text": {}},
        "Status": {"select": {"options": [{"name": "Open"}, {"name": "Done"}]}},
        "Date": {"date": {}},
    },
)
print(new_db["url"])
```

---

## Known gotchas and workarounds

### `paragraph.icon: null` causes 400 when replaying blocks

**Issue:** Fetching a paragraph block may return `"icon": null`. Replaying it via `append_block_children` fails:

> `body.children[0].paragraph.icon should be an object or undefined, not null`

**Fix:** Strip `icon` and any other `null`-valued fields from payloads before sending.

### Cannot move a database via API

**Issue:** `PATCH /databases/{id}` rejects `parent` updates:

> `Parent must be a database_id when provided`

**Workaround:** Create a new database under the target parent, copy all pages, then archive the old database. Even 1000+ rows copy in seconds with auto-pagination.

### Image blocks with signed S3 URLs expire quickly

**Issue:** Fetched image blocks use signed S3 URLs valid for ~1 hour. If you want to replicate the image elsewhere, the URL will expire before you embed it.

**Fix:** Download the image bytes immediately, then re-upload via `file_upload_create` and `file_upload_send` at the destination.

### Property updates silently ignored in some MCP implementations

**Issue:** Some MCP `update_page` commands ignore the `properties` field depending on other parameters.

**Lesson:** Use the REST API directly when reliability matters. Wrappers tend to be thinner and less surprising than MCP abstractions.

### Returned properties include read-only fields

**Issue:** `get_page` includes `id`, `plain_text`, and `href` in rich_text/title items. Echoing these back to `create_page` fails.

**Fix:** Use the helper functions (`title_prop`, `rich_text_prop`) which return clean payloads. If replicating manually, strip read-only fields as shown in pattern #4.

### Rate limiting is real and should be respected

**Published limit:** ~3 requests/second.

**Best practice:** Auto-throttle writes (e.g., 0.3–0.4 seconds between calls) and implement exponential backoff for HTTP 429 and 5xx responses. For large batches (1000+ writes), expect 5–10 minutes total.

---

## Comparison with MCP approach

| Aspect | REST API + Wrapper | MCP |
|---|---|---|
| Pagination | Auto-paginate internally | Hard-coded result cap (often 25) |
| Batch updates | Fast, single function call | Multiple calls required |
| File uploads | Supported | Usually not |
| Rate limiting | Built-in throttle + retry | User's responsibility |
| Code complexity | Slight — thin wrapper | Low — use existing API |
| Best for | Bulk/audit/migration work | Exploratory/one-off operations |

---

## Summary

Use a REST API wrapper (custom or from a library) for **all batch-shaped Notion work**. It eliminates the MCP pagination bottleneck and provides reliable retry logic. Single-page CRUD and exploration are fine with MCP; everything else scales better with the REST API.