To create a page in a Notion database with the API, send POST https://api.notion.com/v1/pages with parent.data_source_id, a properties object that matches the data source's schema, and a Notion-Version header. The connection needs the Insert content capability and access to that database. The ID you send belongs to the data source, not the database ID you see in the page URL. I'd look that data source ID up once and store it in an environment variable called NOTION_DATA_SOURCE_ID, because resolving it on every run spends requests from the same per-minute budget your creates use.
For a team automation that has to keep working when people come and go, choose an internal connection. For a script only you run, choose a personal access token (PAT): it acts as you and skips the sharing step. Every example below uses one Leads database with four properties: Name (title), Status (status), Due date (date) and Email (email).
The minimal request
This is the whole call. Replace YOUR_DATA_SOURCE_ID with your data source ID and keep the token in $NOTION_API_KEY. The Status value has to be an option that already exists in the database.
curl --fail-with-body https://api.notion.com/v1/pages \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Content-Type: application/json" \
-H "Notion-Version: 2026-03-11" \
--data '{
"parent": { "data_source_id": "YOUR_DATA_SOURCE_ID" },
"properties": {
"Name": { "title": [{ "type": "text", "text": { "content": "Jordan Lee" } }] },
"Status": { "status": { "name": "New" } },
"Due date": { "date": { "start": "2026-12-01" } },
"Email": { "email": "jordan@example.com" }
}
}'
The response contains the new page's id; keep it if anything later needs to update that page. Notion's guide to creating pages in a data source spells out the catch: running the same request again creates another page. A retry is never free, which is why the bulk section below starts with duplicates.
2026-03-11 is the version the Create a page reference lists as the latest, and the header is required: leave it out and Notion answers 400 missing_version.
Before the first request: token, capability, sharing
Three things have to be true before Notion accepts the call. You have a token, the token may insert content, and it can see the database. How you get there depends on the kind of token.
Internal connection for team automations
- In the Developer portal, create an internal connection for your workspace. Notion's authorization guide says you have to be a workspace owner to create one.
- Open the connection's Configuration tab and copy the API token. In the Capabilities section on the same tab, turn on Insert content, plus Read content if your code will check the schema first, which I recommend below.
- Open the database in Notion, click the ••• menu at the top right, scroll to Add connections and select your connection.
Insert content alone is enough to create pages. Notion's capabilities reference says a connection that only brings data into Notion only needs Insert content, and that Update content on its own can't create new pages. I'd pick this kind of token for anything a team depends on: it works as a separate bot user that sees only what's shared with it, so it doesn't depend on one person's permissions.
Personal access token for your own scripts
A PAT acts as the person who created it. It reaches what you can reach, it doesn't need Add connections, and it loses access when you lose access or leave the workspace. To create one, open Personal access tokens in the Developer portal, select New token, name it, choose the Notion API capability and an expiration, then select Create token and copy the value, because you can't view it again. If you don't choose an expiration, the token expires after one year, and an expired token returns unauthorized.
Guests and restricted members can't create PATs at all. For full members, Notion's personal access token guide sets the policy by plan:
| Plan | Default PAT creation policy | Admin controls |
|---|---|---|
| Free | Workspace owners only. | Not configurable. |
| Plus | All workspace members. | Not configurable. |
| Business | Workspace owners only. | Admins can choose Workspace owners only or All workspace members. |
| Enterprise | Workspace owners and selected groups. | Admins can choose Workspace owners only, Workspace owners and selected groups, or All workspace members. |
If the token option is missing on Business or Enterprise, Notion's quick start guide says to ask a workspace owner to enable PAT creation in Settings → Connections.
Where the token and ID live
Keep both values in your server's environment as NOTION_API_KEY and NOTION_DATA_SOURCE_ID. Notion's create-pages guide is direct about where the token must never appear: browser page source, client bundles or public repositories. The JavaScript SDK backs this up by throwing BrowserTokenNotAllowedError when you construct it with a token inside a browser page.
Find the data source ID, not the database ID
A database is a container for one or more data sources, and the data source holds the schema and the pages. Notion's guide to working with databases says it plainly: a database response lists data sources and does not contain a properties schema. So the ID in your browser's address bar is only the first step.
- Copy the database ID from the URL. The Retrieve a database reference describes it as the 32-character alphanumeric string between the slash after the workspace name (if there is one) and the question mark.
- Send it to Retrieve a database.
- Read the
data_sourcesarray and pick the source you want by name.
curl https://api.notion.com/v1/databases/YOUR_DATABASE_ID \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2026-03-11"
The part of the response you need looks like this (trimmed):
{
"object": "database",
"id": "YOUR_DATABASE_ID",
"data_sources": [
{ "id": "YOUR_DATA_SOURCE_ID", "name": "Leads" },
{ "id": "ANOTHER_DATA_SOURCE_ID", "name": "Archive" }
]
}
For a one-off, skip the call: in Notion, open the database's settings menu, go to Manage data sources and use Copy data source ID.
Why not keep sending the database ID as database_id? Notion's upgrade guide for API version 2025-09-03 warns that a connection on an older API version starts failing once someone adds another data source to the database: creating a page with the database as parent, reading or querying the database, and writing relations that point to it all fail. The guide asks for a discovery step that fetches and stores the data_source_id, and says the switch works on any API version. That's the reason I store the data source ID even for a database with a single source today.

Check the schema once
Before the first create, read the schema with Retrieve a data source. It returns every property with its name, ID and type. Write the names in your request exactly as the schema returns them; property names or IDs can both serve as keys.
curl https://api.notion.com/v1/data_sources/YOUR_DATA_SOURCE_ID \
-H "Authorization: Bearer $NOTION_API_KEY" \
-H "Notion-Version: 2026-03-11"
This call needs Read content. A 404 here means the data source doesn't exist or the connection can't see it. If your database has a relation, share the related database with the connection as well; otherwise the relation-based properties are missing from the response. When a column is renamed in Notion, a request that still sends the old name no longer matches the schema, and that mismatch is the first thing to check behind a 400 validation_error. I'd run this check once per job, at startup, rather than before every page, since each check is one more request against the same limit.
A schema near Notion's own ceiling is a separate warning sign. The data source reference recommends a maximum of 500 properties or a 50KB schema, and the property count and data size limits are better fixed before you automate writes into that database.
Property JSON by type
Each property in properties takes the shape of its type. Title and text values are arrays of rich text objects; most other types take a single value or a list of IDs or names. The shapes below follow Notion's page property values reference.
| Type | Write shape | Rule worth knowing |
|---|---|---|
| title | {"title": [{"type": "text", "text": {"content": "Jordan Lee"}}]} | Each data source has exactly one. Each text object holds up to 2,000 characters. |
| rich_text | {"rich_text": [{"type": "text", "text": {"content": "Referred by Sam"}}]} | Same 2,000-character cap per text object, and an array holds up to 100 elements, so longer text goes into several objects. |
| number | {"number": 8} | A plain number; the format belongs to the schema. |
| select | {"select": {"name": "Website audit"}} | A new option name adds the option to the schema if your connection can write to the parent data source. Option names can't contain commas. |
| multi_select | {"multi_select": [{"name": "High"}]} | Same option rule; up to 100 options per request. |
| status | {"status": {"name": "New"}} | Write an existing option by name or ID. A page write doesn't create a status option or set its group. |
| date | {"date": {"start": "2026-12-01"}} or {"date": {"start": "2026-12-01T09:00:00", "time_zone": "America/Los_Angeles"}} | With time_zone, put a time in start and end without a UTC offset. Otherwise use a date or a timestamp with an offset. |
| checkbox | {"checkbox": true} | false unchecks it. |
{"email": "jordan@example.com"} | Up to 200 characters. | |
| phone_number | {"phone_number": "+1 415 555 0123"} | Up to 200 characters; the API doesn't enforce a phone format. |
| url | {"url": "https://example.com/projects/launch"} | Up to 2,000 characters. |
| people | {"people": [{"id": "USER_ID"}]} | Up to 100 users per request. Bots used internally by Notion may not be assignable. |
| relation | {"relation": [{"id": "RELATED_PAGE_ID"}]} | Page IDs from the related data source, up to 100 per request. The write replaces the whole list. |
| files | {"files": [{"name": "Project brief.pdf", "type": "external", "external": {"url": "https://example.com/project-brief.pdf"}}]} | An array write replaces the entire list. |
Status and select look alike in JSON but behave differently on write. A select write can add a new option on the fly; a status write fails unless the option already exists, which is one of the practical differences covered in Notion status vs select for project tracking. If your import brings stage names from another tool, create the matching status options in Notion first.
A relation value is a list of page IDs, and every page has to belong to the data source the relation points to; relations and rollups in a client and project database shows that structure from the Notion side. Because a write replaces the whole list, read the existing references before you add one to a page that already has some. Share the related database with your connection too.
Leave read-only types out of the request entirely. Notion sets formula, rollup, ID, created and last-edited values itself, and button actions can't be set through a page write. If you later update a page and need to clear a value, send [] for title, rich_text, people, relation, multi_select and files, null for number, url, email, phone_number, select and date, and false for checkbox; a status set to null resets to the default option, if one is set.
The same request in JavaScript
The official SDK is @notionhq/client (install it with npm install @notionhq/client; it needs Node 22 or later). Its README says it supports both 2025-09-03 and 2026-03-11 and defaults to 2025-09-03, so pass notionVersion to match the curl examples. Notion's versioning page says SDK support for 2026-03-11 starts with v5.12.0, so you need that release or later.
import { Client } from "@notionhq/client";
const notion = new Client({
auth: process.env.NOTION_API_KEY,
notionVersion: "2026-03-11",
});
const dataSourceId = process.env.NOTION_DATA_SOURCE_ID;
async function main() {
// Check the schema once per run.
const dataSource = await notion.dataSources.retrieve({ data_source_id: dataSourceId });
if (!("properties" in dataSource)) {
throw new Error("No schema in the response. Check the connection's access.");
}
const expected = { Name: "title", Status: "status", "Due date": "date", Email: "email" };
for (const [name, type] of Object.entries(expected)) {
if (dataSource.properties[name]?.type !== type) {
throw new Error(`Expected a ${type} property named ${name}.`);
}
}
const page = await notion.pages.create({
parent: { data_source_id: dataSourceId },
properties: {
Name: { title: [{ type: "text", text: { content: "Jordan Lee" } }] },
Status: { status: { name: "New" } },
"Due date": { date: { start: "2026-12-01" } },
Email: { email: "jordan@example.com" },
},
});
console.log(page.id);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
Save it as an ES module such as create-page.mjs, the way Notion's create-pages guide does. The property objects are the same shapes as the curl body; the SDK only adds the headers and the retries described further down.
Add content: children, markdown or a template
Properties fill the database columns. The page body comes from one of three places: children blocks, a markdown string, or a database template. The first two can't be combined, and a template can't be combined with children.

Blocks in children
The Create a page reference accepts up to 100 blocks in children, next to parent and properties:
{
"parent": { "data_source_id": "YOUR_DATA_SOURCE_ID" },
"properties": {
"Name": { "title": [{ "type": "text", "text": { "content": "Jordan Lee" } }] }
},
"children": [
{
"type": "paragraph",
"paragraph": {
"rich_text": [{ "type": "text", "text": { "content": "Asked for a quote on a site refresh." } }]
}
}
]
}
A markdown string
For text-heavy pages, send markdown instead of children, with newlines encoded as \n inside the JSON string. In curl, wrap the --data body in single quotes so \n reaches the JSON parser intact. Enhanced markdown (v1) is the default; markdown_version: "v2" opts into Notion-flavored Markdown, which is a preview, and sending markdown_version without markdown returns 400 validation_error. For a long document, allow_async: true returns HTTP 202 with an async_task object instead of waiting for the page to finish. It changes the response only, not validation or permissions.
{
"parent": { "data_source_id": "YOUR_DATA_SOURCE_ID" },
"properties": {
"Name": { "title": [{ "type": "text", "text": { "content": "Jordan Lee" } }] }
},
"markdown": "## Call notes\n\nAsked for a quote on a site refresh."
}
More than 100 blocks
Create the page first, then add the rest with Append block children (PATCH /v1/blocks/{block_id}/children), using the new page's id as the block ID, since blocks can be parented by pages. Each request takes up to 100 blocks with up to two levels of nesting. Choose where the blocks land with position; the older after parameter is deprecated.
Start from a template
Instead of sending content, you can let a database template fill the page. "template": { "type": "default" } applies the data source's default template, which Notion only allows when a default is configured in the app; setting database template defaults covers that side. To choose a specific template, call List data source templates, which returns up to 100 templates per page and takes a name filter for a case-insensitive substring match, then send its ID:
{
"parent": { "data_source_id": "YOUR_DATA_SOURCE_ID" },
"properties": {
"Name": { "title": [{ "type": "text", "text": { "content": "Jordan Lee" } }] }
},
"template": {
"type": "template_id",
"template_id": "YOUR_TEMPLATE_ID",
"timezone": "America/New_York"
}
}
Notion's guide to creating pages from templates sets the rules your code has to respect:
childrenisn't allowed with a template.- The request returns immediately with a blank page, apart from the properties you sent. Notion applies the template asynchronously afterward, so don't read the content from the create response.
- Template variables such as
@nowand@todayresolve intemplate.timezone. Leave it out and internal connections use UTC, while PATs and public connections use the user's time zone. An invalid zone returnsvalidation_error. - Asking for the default template when the data source has none is a 400
validation_error.
To know when the content has arrived, listen for the page.content_updated webhook, or poll Retrieve block children with a timeout. The wait needs that timeout: on a Free workspace that hits its block limit, the background template job can stop after a successful create and leave the page blank or half-filled, and there's no template-failure webhook or public job-status endpoint to tell you. I would flag that page for a person to check rather than reapply the template automatically, because reapplying can duplicate content that already copied.
Silent notifications
Add "notifications": { "mode": "silent" } to skip the page-update, @mention and Person-property notifications a new page would send. It doesn't hide the page from the rest of the workspace: database automations triggered by the new page still run, including Send notification to actions, reminders still go off, and connection webhooks still receive events.
When the request fails
Notion answers with an HTTP status and a code in the body. These are the ones a create request is likely to meet, with codes exactly as Notion's status code reference lists them.
| Status and code | What Notion says it means | What to check first |
|---|---|---|
400 validation_error | The body doesn't match the expected parameters; the message has details. | Property names and value shapes against the schema, a size limit, a missing default template or an invalid time zone. |
400 missing_version | The required Notion-Version header is missing. | Add the header to every request. |
400 invalid_json | The body couldn't be decoded as JSON. | Quoting in curl: single quotes around --data. |
401 unauthorized | The bearer token is not valid. | The token value; an expired PAT returns this too. |
403 restricted_resource | The token lacks permission, or the request exceeds a workspace block limit. | Insert content on the connection. If additional_data.block_limit is present, retrying won't help. |
404 object_not_found | The resource doesn't exist for this token, or hasn't been shared with it. | Whether you sent the database ID from the URL, then the database's connections. |
409 conflict_error | The transaction couldn't complete, possibly because of a data collision. | Make sure the parameters are up to date, then retry. |
429 rate_limited | The request exceeds the number allowed. | Pause for Retry-After seconds. |
502 bad_gateway, 504 gateway_timeout | Notion couldn't complete the request or timed out. | Whether the page exists before you send the create again. |
503 service_unavailable | Notion is unavailable or couldn't build the response in time; a write can save and still return this. | additional_data.retry_guidance and committed_resource_id. |
529 service_overload | Notion is temporarily overloaded. | Pause for Retry-After seconds. |
On a 404, the first thing to check is whether parent.data_source_id holds the database ID copied from the URL instead of the data source ID. If the ID is right, open the database's ••• menu and confirm your connection is added under Add connections; with a PAT, confirm you can open the database yourself. On a 400, read the message: it usually names the property that failed, and the fix is to match the current schema. On a 403, the connection is missing Insert content, or the workspace hit its block limit, which the bulk section covers.
Timeouts and 503s need more care, because the page may already exist. A write can save its change and still return 503, so read additional_data.retry_guidance before retrying: guidance such as "Do not repeat the write." means exactly that. When the request created a page, additional_data.committed_resource_id holds the new page's ID. After a timeout on your side there's no response to read, so check whether the page was created before you send the request again.
Bulk imports without duplicates or 429s
One page per request means an import is mostly a question of limits. These are the numbers that decide how fast it can run, from Notion's request limits page unless the row says otherwise.
| Limit | Value | Conditions |
|---|---|---|
| Requests per connection | 600 per minute (an average of 10 per second) | Business and Enterprise |
| Requests per connection | 180 per minute (an average of 3 per second) | All other plans |
Retry-After on that limit | At most 60 seconds | Time until the window resets; an integer number of seconds |
| Per-workspace limit | Shared by all of the workspace's connections | Scaled to the plan; its Retry-After can be longer than a minute |
| Querying a data source | About 3 requests per second | Per connection, separate from the limits above |
| Payload size | 1,000 block elements and 500KB | Per request |
Blocks in children | 100 | Per Create a page or Append block children request |
| Lifetime blocks | 1,000 | Free workspaces with more than one member; internal connections, not PATs |
| SDK retries | 2 by default | JavaScript SDK; 429 and 529 for all methods, 500 and 503 for GET and DELETE only |
Here is the arithmetic for 1,000 new pages at one request each, with properties only: 1,000 ÷ 180 per minute is about 5.6 minutes on most plans, and 1,000 ÷ 600 is about 1.7 minutes on Business and Enterprise. Those are floors computed from the documented limits. The schema check, any Append block children calls, retries and the shared workspace limit only add to them.
Plan size matters in a second way. On a Free workspace with more than one member, an internal connection reaches the workspace's 1,000 lifetime blocks before such an import finishes, because each new page is a block and the blocks already in the workspace count too. Per Notion's workspace block limits page, the write that reaches the limit still succeeds and starts a three-day grace period; after grace, Create a page fails with 403 restricted_resource, and retrying without a change to the workspace won't fix it. A PAT is outside that API limit.
One queue that honors Retry-After
Send every create through a single queue. When a 429 or 529 arrives, pause the whole queue for at least the Retry-After seconds, then retry the failed request. Notion's guidance is to use the returned value rather than a fixed wait, and to avoid independent retry loops in each worker, which can create a second traffic spike when their delays expire. I'd skip the fixed sleep between requests for the same reason: it guesses at a limit that the response already states.
The JavaScript SDK does part of this for you. The client retries up to 2 times by default and waits for Retry-After, capped at 60 seconds by default; when the header is missing, it backs off with growing delays and a random offset. It retries 429 and 529 for every method, but 500 and 503 only for GET and DELETE, and it never retries 502 or 504, so a create that hit a server error isn't silently repeated.
A key for every row
Duplicate protection is yours to build: Notion's request limits page says to retry 500, 502, 503 and 504 only for idempotent requests unless your application has its own idempotency protection, and a create isn't idempotent. My recommendation, not a Notion feature, is a stable key per row taken from the source system, such as a form submission ID or a CRM record ID. Write it into a text property called Source ID, and record key-to-page-ID pairs outside Notion as each create succeeds. After a timeout or a 503, look for the key before sending the create again: use committed_resource_id when the error carries it, otherwise query the data source for that Source ID.
import { Client } from "@notionhq/client";
const notion = new Client({
auth: process.env.NOTION_API_KEY,
notionVersion: "2026-03-11",
retry: { maxRetries: 5 }, // default is 2
});
const dataSourceId = process.env.NOTION_DATA_SOURCE_ID;
// rows: records from your source system, each with a stable key
// created: a Map of key to page ID that you save between runs
async function importRows(rows, created) {
for (const row of rows) {
if (created.has(row.key)) continue;
const page = await notion.pages.create({
parent: { data_source_id: dataSourceId },
properties: {
Name: { title: [{ type: "text", text: { content: row.name } }] },
"Source ID": { rich_text: [{ type: "text", text: { content: row.key } }] },
},
});
created.set(row.key, page.id);
}
}
Awaiting each create in turn makes the loop its own single queue, and the client handles the 429 pauses. I'd raise maxRetries for a long import: the client caps each wait at 60 seconds, while a Retry-After from the shared workspace limit can run longer, and the extra attempts cover that gap instead of stopping the run halfway. If the loop does stop on an error, the saved map tells a rerun which rows are done, and the Source ID check covers the one row that was in flight.
When you don't need the API
Some of these jobs don't need code at all. If you only need form submissions in a database, a form connector may need no code; connecting a form to a Notion database compares Tally, Typeform, Google Forms and the other routes. For client inquiries, Notion's own form can be enough, and Notion forms for client intake covers the setup and the follow-up.
The same goes for pages that should appear when someone clicks something or a record changes inside Notion. If a button or automation can do it, you don't need code; Notion buttons vs. database automations helps you pick the right trigger. I'd keep the API for data that starts outside Notion.
What I would choose, and the first thing to do
For a team automation, I'd use an internal connection with Insert content and Read content, add it through ••• → Add connections, and keep the data source ID in NOTION_DATA_SOURCE_ID. It runs on its own bot user, so a teammate leaving doesn't take its access away. For a script only you run, I'd use a PAT: there's no sharing step, and it sits outside the Free block limit. Either way, check the schema once per run, and give every row a key before you import more than a handful.
If the database is still changing shape, settle how the database is organized before you automate it, since every renamed or retyped property is a request that stops matching. Your first action: call Retrieve a database with the ID from the URL, copy the right ID from data_sources, and store it.
Questions that come up next
Can I still use database_id as the parent?
The Create a page request schema still lists a database_id parent, but the upgrade guide tells you to change it to data_source_id, and on older API versions a database parent fails once someone adds a second data source. Since the change works on any version, I'd switch now rather than wait for that second source to appear.
Can I set a formula or rollup value?
No. Formula, rollup, ID, creation and last-edit values are set by Notion, so leave them out of properties. Write the relation, and Notion computes the rollup from it; Notion relation vs rollup explains which property holds the link and which one calculates from it.
Can I create the page outside a database?
Yes, under a regular page, but then title is the only valid property. A PAT or a public connection can also create a private page at the workspace level by omitting parent; an internal connection needs a page or data source parent.