Skip to content
Innovialabs

Notion API: How to Connect Databases to Your Site or Bot

Advanced4 min

In short

Create an internal integration, add it to your database via the Connections menu and call the API from your server with the token: a database query returns up to 100 entries at a time, and a 429 response means wait and retry.

The short answer

The Notion API is a REST API that lets your server read and modify Notion pages and databases. The flow is always the same:

  1. Create an integration and get a secret token.
  2. Share specific pages or databases with it — by default it sees nothing.
  3. Send requests to api.notion.com with the token and a Notion-Version header.

That turns Notion into a lightweight CMS for a blog, a lead table for a Telegram bot or a reference base for an internal tool.

Step 1. Create an integration

  • Open the integrations section of your Notion settings and create an internal integration for your workspace.
  • Pick its capabilities: read content only, or also insert and update. Grant the minimum you need.
  • Copy the Internal Integration Secret and store it in your server's environment variables.

Important: the token must never end up in frontend code or a mobile app bundle. All Notion requests go through your backend.

Step 2. Give it access to the database

An integration only sees what has been shared with it. Open the database page, choose Connections in the "..." menu and add your integration. Access is inherited by child pages.

The database ID comes from its URL — the 32-character string before ?v=.

The most common beginner issue is an object_not_found error with a correct ID. It almost always means the database was never connected to the integration.

Step 3. Read the database with pagination and rate limits

A database query returns at most 100 entries per call. If the response has has_more: true, repeat the request with start_cursor set to next_cursor.

The rate limit averages around three requests per second per integration. Above that the API answers 429 with a Retry-After header — wait that many seconds before retrying.

const headers = {
  Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
  "Notion-Version": "2022-06-28",
  "Content-Type": "application/json",
};

async function queryAll(databaseId, body = {}) {
  const results = [];
  let cursor;
  do {
    const res = await fetch(`https://api.notion.com/v1/databases/${databaseId}/query`, {
      method: "POST",
      headers,
      body: JSON.stringify({ ...body, page_size: 100, start_cursor: cursor }),
    });
    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") || 1);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`Notion API error ${res.status}`);
    const data = await res.json();
    results.push(...data.results);
    cursor = data.has_more ? data.next_cursor : undefined;
  } while (cursor);
  return results;
}

Newer API versions split databases into data sources, and the query endpoint differs. If you pick a recent Notion-Version, check the official documentation.

Example: Notion as a blog CMS

Create a database with Name (title), Slug (text), Status (select) and Date (date) properties. Fetch only published posts:

const pages = await queryAll(process.env.NOTION_POSTS_DB, {
  filter: { property: "Status", select: { equals: "Published" } },
  sorts: [{ property: "Date", direction: "descending" }],
});

const text = (arr) => arr.map((t) => t.plain_text).join("");

const posts = pages.map((p) => ({
  id: p.id,
  title: text(p.properties.Name.title),
  slug: text(p.properties.Slug.rich_text),
  date: p.properties.Date.date?.start,
}));

If Status is a status-type property rather than a select, the filter becomes status: { equals: "Published" }.

The article body is made of blocks: GET /v1/blocks/{page_id}/children. They are also paginated by 100, and nested blocks (has_children: true) need separate requests.

Writing: bot leads into a database

A new entry is a new page whose parent is the database:

await fetch("https://api.notion.com/v1/pages", {
  method: "POST",
  headers,
  body: JSON.stringify({
    parent: { database_id: process.env.NOTION_LEADS_DB },
    properties: {
      Name: { title: [{ text: { content: name } }] },
      Phone: { phone_number: phone },
      Source: { select: { name: "Telegram" } },
    },
  }),
});

To update an entry, send PATCH /v1/pages/{page_id} with the same property objects.

Common mistakes

  • Calling Notion on every page view. You will hit the limits fast. Cache the result or build pages statically with periodic revalidation.
  • Relying on property names. Rename a column and the integration breaks. Use property IDs for robustness.
  • File links. URLs of files uploaded to Notion are temporary. For a website, copy files to your own storage.
  • Long text in one object. Rich text has a length limit per text object — split long content into chunks.
  • No handling for 429 and 5xx. Retries with a delay are mandatory for any integration.

When Notion fits and when it does not

TaskNotionProper DB/CMS
Blog, knowledge base, FAQGood, with cachingOverkill
Low-volume bot leadsConvenient for the teamAlso fine
High load, frequent writesHits rate limitsNeeded
Complex relations and transactionsNot suitableNeeded

FAQ

No. The token grants access to every connected page, so requests must come from a server — an API route or a serverless function, for example.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.