Pull external data into Notion databases and keep it up to date.
A sync pulls data from external sources like Salesforce, Stripe, and GitHub and writes it to a Notion database. You define a schema for the database and an execute function that returns the data. Notion runs it on a schedule and manages the database for you.
Every sync needs a database to write to. Declare one with worker.database(), then register a sync that targets it:
src/index.ts
import { Worker } from "@notionhq/workers";import * as Builder from "@notionhq/workers/builder";import * as Schema from "@notionhq/workers/schema";const worker = new Worker();export default worker;const issues = worker.database("issues", { // only "managed" type is supported for now type: "managed", // the initial title of the database initialTitle: "Issues", // the property that uniquely identifies each row primaryKeyProperty: "Issue ID", // the schema defines the structure of the database schema: { // define each database property and its type properties: { Name: Schema.title(), "Issue ID": Schema.richText(), Status: Schema.richText(), }, },});worker.sync("issuesSync", { // ...});
primaryKeyProperty tells Notion which property uniquely identifies each row. This is typically the entity’s ID in the external API (e.g., a Salesforce Contact ID or GitHub issue ID). When your sync emits a record with the same key, Notion updates the existing row instead of creating a duplicate.
Syncs currently create and manage their own databases. Support for syncing to existing databases is coming soon.
The schema.properties object defines the columns of your Notion database. Each property uses a Schema helper to declare its type, and each upsert uses the corresponding Builder helper to set its value.For the full list of supported property types, see Schema and builders.
Each upsert can optionally set page metadata with icon and cover. Use Builder.imageCover() with an external image URL and an optional position from 0 (top) to 1 (bottom):
Workers support two sync modes. Pick the one that fits your needs:
Replace (default)
Incremental
Each sync cycle returns the full dataset. After the final hasMore: false, any rows not seen during that cycle are automatically deleted.Best for smaller datasets (under 10k records) or APIs that don’t support change tracking. Also used as the backfill half of a backfill + delta pair.
Each sync cycle returns only changes since the last run. Rows not mentioned are left as-is. Deletions must be explicit.Best for large datasets (10k+ records) or APIs that provide a changes endpoint or cursor. Typically used as the delta half of a backfill + delta pair.
A schedule controls how often Notion triggers your sync. Each time it triggers, the runtime calls execute repeatedly until it returns hasMore: false, then waits for the next scheduled trigger. The default schedule is every 30 minutes.
A single replace sync works for small datasets, but most real integrations need two things: fast updates (minutes, not hours) and the ability to re-sync everything when needed. You get both by registering two syncs against the same database:
A delta sync runs on a schedule and fetches only what changed since the last run. This keeps the database near-real-time.
A backfill sync paginates the entire upstream dataset. You trigger it manually, for example after a schema change, to populate a new property, or to catch anything the delta missed.
Since both syncs share a database and key space, upserts from both operate on the same rows. The delta keeps the database current and the backfill re-syncs the full dataset when you need to:
Delta sync
Backfill sync
Mode
incremental
replace
Schedule
"5m" or "30m"
"manual"
What it does
Grabs recent changes via updated_since or a change feed
In this example, to run a backfill at any point in the future, you’d reset the state then trigger the sync to start running:
ntn workers sync state reset ticketsBackfillntn workers sync trigger ticketsBackfill
This pattern gives you operational flexibility: run a backfill after a schema change to populate a new property, or after a bug fix to correct drifted data. This pattern also handles deletes cleanly even when the API doesn’t surface them, as the backfill’s replace-mode mark-and-sweep catches anything the delta missed.
If both syncs hit the same API, give them the same pacer. The runtime automatically splits the rate limit budget between them.
In the example above, Schema.relation("projects") references the database name projects from worker.database("projects", ...), and the twoWay: true option adds a “Tasks” rollup column to the Projects database automatically.
Most syncs need credentials for the external API they pull from. You have two options:
API keys and tokens: store them as secrets and read from process.env.
OAuth: for APIs that require user authorization (GitHub, Google, Salesforce), register an OAuth capability and call accessToken() in your execute function.
To call the Notion API from a sync (e.g., to read pages or update properties beyond sync changes), see Using Notion API from a worker.
# Live-updating status dashboardntn workers sync status# Preview output without writing to the databasentn workers sync trigger <syncKey> --preview# Trigger a real sync immediatelyntn workers sync trigger <syncKey># Reset sync state (restart from scratch)ntn workers sync state reset <syncKey># Pause a syncntn workers capabilities disable <syncKey># Resume a syncntn workers capabilities enable <syncKey>
Deploying does not reset sync state. Syncs resume from their last cursor position. See Resetting and migrating state below.
Deploys never clear sync state. Your sync picks up where it left off. If you need to start fresh (e.g., after changing your schema or fixing a bug in your execute function), reset the state:
ntn workers sync state reset <syncKey>
This clears the stored nextState so the next run starts from scratch, as if the sync had never run before.To inspect the current state before deciding whether to reset:
Check ntn workers sync trigger <syncKey> --preview to see what your execute function returns without writing to the database. If the preview is empty, the issue is in your data-fetching code.
Make sure the key in each change matches the property named by primaryKeyProperty.
Each row needs a unique key. If two changes share the same key, the second overwrites the first. If keys differ, Notion creates separate rows. Double-check that your key is the stable external ID, not a value that changes between runs.
Replace mode only deletes stale rows after the final page returns hasMore: false. If your sync errors partway through, no deletions happen (this is intentional to avoid data loss).