Notion Database in a Custom Dashboard: Official API vs Zapier vs Scraping
Choosing between the Notion API, Zapier, and scraping to feed a custom dashboard: a comparison of reliability, latency, and property support, plus a working Python sync example.
15 Sept 2025, 12:01 UTC

You have a Notion database — a content calendar, a bug tracker, an inventory list — and you want its rows displayed in a custom dashboard your team actually looks at. The decision is how to get the data out: the official Notion API, a no-code bridge like Zapier, or scraping the page. The short answer: use the official API unless you have no developer available at all. Here is why, and how to validate the result.
The decision and its constraints
Three constraints usually drive this choice:
- Freshness: how stale can the dashboard be? Seconds, minutes, or a daily sync?
- Volume: a 50-row database and a 5,000-row database have very different sync profiles.
- Maintenance: who fixes the pipeline when it breaks, and how often can it break before people stop trusting the dashboard?
Comparing the supported options
| Criteria | Official Notion API | Zapier (or similar) | Web scraping |
|---|---|---|---|
| Reliability | Documented, versioned endpoints | Depends on Zapier's Notion app and your plan's task limits | Breaks on any UI change |
| Latency | Direct; you control polling interval | Minutes (polling triggers) unless on higher tiers | Fast but fragile |
| Property support | Native: select, multi-select, relation, rollup, formula | Flattened; complex properties often arrive as raw JSON strings | Whatever the DOM shows; relations and rollups are lossy |
| Rate limits | ~3 requests/second average per integration | Task-based billing; hidden throttling on bursts | Unofficial; risk of IP blocks |
| Terms of service | Supported path | Supported path | Violates Notion's terms |
| Cost | Free; you pay in engineering time | Subscription scales with task volume | "Free" until it breaks or gets blocked |
Scraping is listed only to rule it out: it violates Notion's terms, breaks silently when the frontend changes, and forces you to handle session cookies — a security liability. Do not build a business dashboard on it.
Trade-offs that actually matter
API vs Zapier is really a build-vs-rent decision. Zapier is reasonable when the flow is one row at a time ("when a new database item is created, post it somewhere") and nobody on the team writes code. It falls apart for full-database reads: pulling 2,000 existing rows through Zapier burns tasks fast, and transforming nested properties (a multi-select with its colors, a relation resolved to page titles) is painful in a no-code UI.
The API's real cost is rate limiting and token management. Notion enforces an average of about three requests per second per integration, and paginated queries return at most 100 results per call. A large initial sync must page through next_cursor and back off on HTTP 429 responses. OAuth-based public integrations also need token refresh logic; a leaked or expired token that nobody rotates is the most common cause of "the dashboard just stopped updating."
Property types shape your rendering code. The API returns structured JSON per property type — a multi_select is an array of named options, a relation is a list of page IDs you must resolve with follow-up requests. Budget time for this mapping layer; it is where most of the work lives, not in the HTTP calls.
Concrete implementation: read a database with the official client
The example below uses Python with the official notion-client package. Run it anywhere with Python 3.9+ and network access. You need an internal integration token (created at notion.so/my-integrations) and the integration must be shared with the target database in the Notion UI, or the query returns an empty result set — the most common setup mistake.
pip install notion-clientimport os, time
from notion_client import Client
notion = Client(auth=os.environ["NOTION_TOKEN"])
DATABASE_ID = "a1b2c3d4e5f6..." # 32-char ID from the database URL
def fetch_all_rows():
rows, cursor = [], None
while True:
resp = notion.databases.query(
database_id=DATABASE_ID,
start_cursor=cursor,
page_size=100,
)
rows.extend(resp["results"])
if not resp["has_more"]:
return rows
cursor = resp["next_cursor"]
time.sleep(0.4) # stay under the ~3 req/s average
rows = fetch_all_rows()
print(f"Fetched {len(rows)} rows")
for page in rows[:5]:
title_prop = page["properties"]["Name"]["title"]
print(title_prop[0]["plain_text"] if title_prop else "(untitled)")Expected result: the row count matches what you see in the Notion UI, and the first five titles match. If you get zero rows with no error, check that the integration is connected to the database (database menu → Connections). If you get a 401, the token is wrong or revoked; a 429 means you are polling too fast — add exponential backoff (double the sleep on each 429, reset on success).
For a dashboard, run this on a schedule (cron, a worker, or a small serverless function), cache the results in your own store, and re-sync incrementally by filtering on last_edited_time rather than re-fetching everything.
Limitations to plan for
- The API does not push changes to you; there is no general webhook for "any row changed," so dashboards poll. Near-real-time means polling every minute or two within rate limits, not instant updates.
- Some content is not fully exposed: certain block types and embeds return partial data, and comments require separate endpoints.
- Relation properties only give you page IDs. Resolving titles costs one extra request per related page unless you cache them.
- Rate limits are per integration, so one token serving both a sync job and interactive dashboard edits can throttle itself. Use separate integrations if that becomes a problem.
How to verify the pipeline is healthy
Three practical checks: compare the fetched row count against the database's count in the Notion UI after each sync; log the next_cursor pagination path on large syncs to confirm you are not silently truncating; and record the notion-request-id response header for any failed call — Notion support asks for it when debugging. A dashboard that shows a "last synced at" timestamp built from these checks earns more trust than one that silently goes stale.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.