Updated: · Automation Notes admin

Write a Row to the Notion API with Only Python's Standard Library

"I just want to record a single row in a Notion database once the process finishes." Setting up a virtual environment (venv) and running pip install requests just for that feels a bit excessive. Moreover, in automated scripts, it introduces a potential point of failure if the script runs on a machine where the library isn't installed.

I built a script using only the standard library's urllib to log a review task to Notion whenever an automated run fails. In this article, I'll explain how to set it up, using a household chore example (a shopping list item).

Why I Chose the Standard Library

The official Python documentation recommends the requests package as a more feature-rich HTTP client. However, all we need to send to the Notion API is a single JSON POST request. Because urllib.request is more than enough for this, I chose the standard library for the following reasons:

Workflow for Writing to Notion

  1. 1. IntegrationCreate it and obtain the token
  2. 2. ShareUse "Add connections" on the target
  3. 3. Store the tokenIn an environment variable or .env, not in code
  4. 4. POSTCreate a page with urllib
Steps 1 to 3 before writing code account for most stumbling blocks.

Preparing the Token

The official Notion documentation explains how to create an internal integration: create a new connection in the developer portal, then retrieve the API token from the "Configuration" tab (workspace owner permissions are required). Additionally, Notion officially provides "personal access tokens" that operate under your own user permissions.

Next, open the target page or database and select the connection you created from "Add connections" in the top-right menu. The official documentation notes that without this sharing step, the connection cannot interact with the page.

This is where I once got stuck. When I reused an existing token for another purpose, the newly targeted page hadn't been shared yet, causing it to fail silently with a 404 error. I have detailed this in Notion API object_not_found (404): What to Check First.

Regarding token management, the official documentation recommends reading it from an environment variable rather than storing it in source code or version control. In my setup, I write a single line like NOTION_TOKEN=… in a .env file located in the same directory as the script, which the script then reads. Note that Python's os.environ does not read .env files automatically. The code below falls back to reading .env with a few lines if the environment variable isn't set. If you use a synced folder or Git, don't forget to exclude .env.

Minimal Code to Create a Single Page

The endpoint for creating a page is POST /v1/pages. The request requires an Authorization: Bearer token and a Notion-Version header. According to the official documentation, Notion-Version is required on all requests, with the latest version as of October 2026 being 2026-03-11.

The following example adds a single shopping item row to a database with two properties: "Name" and "Date" (property names are examples; adjust them to match your own database schema).

import json
import os
import urllib.error
import urllib.request
from pathlib import Path

NOTION_VERSION = "2026-03-11"
DATA_SOURCE_ID = "YOUR_DATA_SOURCE_ID"


def load_token():
    """Read NOTION_TOKEN from the environment, or from .env next to this script"""
    token = os.environ.get("NOTION_TOKEN")
    if token:
        return token
    env_file = Path(__file__).with_name(".env")
    if env_file.exists():
        for line in env_file.read_text(encoding="utf-8").splitlines():
            if line.startswith("NOTION_TOKEN="):
                return line.split("=", 1)[1].strip()
    raise SystemExit("NOTION_TOKEN not found")


TOKEN = load_token()

payload = {
    "parent": {"type": "data_source_id", "data_source_id": DATA_SOURCE_ID},
    "properties": {
        "Name": {"title": [{"type": "text", "text": {"content": "Buy milk"}}]},
        "Date": {"date": {"start": "2026-10-20"}},
    },
}

req = urllib.request.Request(
    "https://api.notion.com/v1/pages",
    data=json.dumps(payload).encode("utf-8"),
    method="POST",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Notion-Version": NOTION_VERSION,
        "Content-Type": "application/json",
    },
)

try:
    with urllib.request.urlopen(req, timeout=15) as resp:
        print("Created successfully:", resp.status)
except urllib.error.HTTPError as e:
    print("Failed: HTTP", e.code, e.read().decode("utf-8", errors="replace"))
    raise SystemExit(1)
except OSError as e:
    print("Connection failed:", e)
    raise SystemExit(1)

There are three key points:

Specifying the "Parent" When Adding a Row to a Database

Notion's official upgrade guide explains that API version 2025-09-03 introduced data sources, and tells you to switch the parent to data_source_id when adding a row to a database. The page creation reference still lists database_id as a parent type, but following the guide and using data_source_id is the safe choice. In the older version (2022-06-28), you used database_id.

Notion-VersionParent when adding a row to a database
2022-06-28{"database_id": "…"}
2025-09-03 and later{"data_source_id": "…"} (per the official guide)

You can retrieve the data source ID from the data_sources list returned by the retrieve database API, or via "Manage data sources" > "Copy data source ID" in the database settings within Notion. Do not confuse this with the ID extracted from the URL, which is the database ID. To differentiate IDs from URLs, you can use the Notion ID Finder.

If you are creating a page as a child of a standard page, the parent is specified with page_id, and the only property you can specify is title (as noted in the official reference).

Reading Error Details

When a request fails, an HTTPError is raised. The official Python documentation notes that this exception can also function as a response instance, allowing you to read the body with read(). In the official Notion documentation, the error response body contains code and message. The HTTP status code (such as 404) can be retrieved via e.code.

except urllib.error.HTTPError as e:
    body = json.loads(e.read().decode("utf-8", errors="replace"))
    print(e.code, body.get("code"), body.get("message"))
StatuscodeOfficial description
400validation_errorThe request body does not match the expected schema. Check the message.
401unauthorizedThe token is invalid.
404object_not_foundThe resource does not exist for this token, or it has not been shared.
429rate_limitedToo many requests. Retry after a backoff period.

The example above exits with code 1 on failure. My actual script, though, is an add-on that reports failures of other automated runs, so it doesn't stop when the write fails; it just prints the status and response body to standard error (sys.stderr). However, letting write failures pass silently means you might not notice them for days. I discuss mechanisms for catching automated execution failures in Catch Task Scheduler Failures: Check Your Automated Runs Every Morning.

Summary