Build a read-only Notion dashboard without webhooks
How to serve a lightweight internal dashboard from a Notion database using server-side polling, pagination cursors, and property transformation—without native webhooks.
30 Dec 2025, 17:35 UTC

The problem: your team wants Notion data without more Notion seats
Your product team tracks roadmap items in a Notion database, but only a few people have Notion licenses. Everyone else wants a read-only dashboard showing status, owner, and due date. The tempting shortcut is to call the Notion API directly from the frontend. That is a bad idea: it exposes an integration token to anyone who can open your browser's developer tools, and browsers are not a safe place to handle pagination cursors or rate-limit retries.
The practical alternative is a small backend service that polls Notion on a schedule, caches the results, and exposes a lightweight JSON endpoint for your internal dashboard. This post covers how to query a Notion database, handle pagination, stay within rate limits, and flatten Notion's nested property objects into something displayable.
1. Authenticate from the backend, never the frontend
Notion authenticates API calls with a bearer token from an internal integration or an OAuth application. For an internal dashboard, create an internal integration in Notion, copy its secret, and add the integration to the target database with at least read access. Store the secret in your backend's environment variables, not in frontend code.
A request also needs the Notion-Version header. This example assumes the 2022-06-28 version; confirm the current version in Notion's developer documentation before shipping.
2. Query, paginate, and handle HTTP 429
Use POST /v1/databases/{database_id}/query to read a database. Notion returns results in pages of up to 100 items. Each response includes has_more (boolean) and next_cursor (string or null). When has_more is true, send the next request with next_cursor in the body until the cursor is null.
Notion enforces rate limits at roughly three requests per second on average, with some burst capacity. Exceeding the limit returns HTTP 429. Your poller should catch 429, wait, and retry, rather than failing hard. Because Notion has no native webhooks, the dashboard must poll on a schedule or refresh its cache after users trigger a manual sync. That means data is eventually consistent, and you spend rate-limit budget even when nothing changed.
You can reduce payload size by applying filter and sort server-side in the query body instead of fetching everything and filtering in your code.
3. Flatten nested properties before rendering
Notion does not return plain strings for most properties. Rich text, relations, rollups, and formula values arrive as nested objects. Your backend should transform each page into a flat record the frontend can consume, and it should tolerate unknown or missing properties because a teammate can change the database schema at any time.
For example, a rich_text property might look like:
"Status": {
"type": "rich_text",
"rich_text": [
{ "type": "text", "text": { "content": "In progress" }, "plain_text": "In progress" }
]
}
A transformer can extract plain_text from the first element, or fall back to an empty string when the array is absent.
Worked example: verify access and fetch one page
Run these commands from a terminal on a machine you control, such as a laptop or build runner. Do not run them from a browser.
Required permissions: the Notion internal integration must be invited to the database and granted read capability. The token must never be committed to version control.
# Set placeholders in your shell
export NOTION_TOKEN="secret_your_integration_token_here"
export DATABASE_ID="your_database_id_here"
# 1. Confirm authentication works
curl -s https://api.notion.com/v1/users/me -H "Authorization: Bearer $NOTION_TOKEN" -H "Notion-Version: 2022-06-28"
# 2. Fetch the first page of database results
curl -s -X POST https://api.notion.com/v1/databases/$DATABASE_ID/query -H "Authorization: Bearer $NOTION_TOKEN" -H "Notion-Version: 2022-06-28" -H "Content-Type: application/json" -d '{"page_size": 100}'
Expected checks: the first call should return HTTP 200 and a user object. The second should return HTTP 200 with a results array, a has_more boolean, and a next_cursor field that is either a string or null. If you repeat the second call rapidly, you may receive HTTP 429; treat that as confirmation that your client needs backoff logic.
Risks: exporting the token in a shell makes it visible to other processes on the same machine, and shell history may retain it. Use a secrets manager or environment file in production. These are read-only calls, so no rollback is required.
Limitation: polling latency and expiring cursors
The main trade-off is freshness. Without native webhooks, a dashboard updated every five minutes can show data up to five minutes old. Pagination cursors also expire and should not be stored long-term; resume from the last known cursor only within a short polling session.
Practical way to check the result: after deploying the poller, edit a page property in Notion, then call your dashboard endpoint and note how long it takes for the change to appear. That interval is your real-world polling latency. Verify current rate limits and webhook availability in Notion's documentation before you treat this design as production-ready.
Closing
Build the dashboard as a backend-cached service, not a frontend integration. Poll the database with server-side filters, paginate with cursors, back off on HTTP 429, and flatten nested properties into flat records. Accept that the data is eventually consistent, and verify Notion's current API limits before you ship.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.