Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developers.notion.com/llms.txt

Use this file to discover all available pages before exploring further.

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.

Define a database and sync

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.

Schema and properties

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.

Choose a sync mode

Workers support two sync modes. Pick the one that fits your needs:
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.
worker.sync("teamsSync", {
  database: teams,
  mode: "replace",
  execute: async (state) => {
    const page = state?.page ?? 1;
    const { items, hasMore } = await fetchPage(page, 100);
    return {
      changes: items.map((item) => ({
        type: "upsert" as const,
        key: item.id,
        properties: {
          Name: Builder.title(item.name),
          ID: Builder.richText(item.id),
        },
      })),
      hasMore,
      nextState: hasMore ? { page: page + 1 } : undefined,
    };
  },
});

Paginate large datasets

When syncing more than a few hundred records, break the work into batches. The runtime calls execute repeatedly until you return hasMore: false:
  1. Return a batch of changes with hasMore: true and a nextState value.
  2. The runtime calls execute again, passing that state back as the first argument.
  3. Repeat until you return hasMore: false.
nextState can be any serializable value, such as a cursor string, page number, timestamp, or object. Start with batch sizes of ~100 records.
worker.sync("paginatedSync", {
  database: records,
  execute: async (state) => {
    const { items, nextCursor } = await fetchPage(state?.cursor);
    return {
      changes: items.map((item) => ({
        type: "upsert" as const,
        key: item.id,
        properties: {
          Name: Builder.title(item.name),
          ID: Builder.richText(item.id),
        },
      })),
      hasMore: Boolean(nextCursor),
      nextState: nextCursor ? { cursor: nextCursor } : undefined,
    };
  },
});

Set a schedule

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.
worker.sync("frequentSync", {
  database: myDb,
  schedule: "5m",
  // ...
});
ValueBehavior
"5m", "15m", "1h", "1d"Run at the given interval
"manual"Only run when triggered via the CLI
Minimum schedule is "5m", maximum is "7d".

Combine backfill and delta syncs

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 syncBackfill sync
Modeincrementalreplace
Schedule"5m" or "30m""manual"
What it doesGrabs recent changes via updated_since or a change feedPaginates the entire upstream dataset
DeletesEmits type: "delete" if the API supports itMark-and-sweep catches everything else
When it runsContinuously on scheduleOn demand
// Delta: near-real-time updates
worker.sync("ticketsDelta", {
  database: tickets,
  mode: "incremental",
  schedule: "5m",
  execute: async (state) => {
    await apiPacer.wait();
    const { items, nextCursor } = await fetchTicketChanges(state?.cursor);
    return {
      changes: items.map((t) => ({
        type: "upsert" as const,
        key: t.id,
        properties: {
          Summary: Builder.title(t.summary),
          "Ticket ID": Builder.richText(t.id),
        },
      })),
      hasMore: Boolean(nextCursor),
      nextState: nextCursor ? { cursor: nextCursor } : undefined,
    };
  },
});

// Backfill: full dataset sweep, run manually
worker.sync("ticketsBackfill", {
  database: tickets,
  mode: "replace",
  schedule: "manual",
  execute: async (state) => {
    const page = state?.page ?? 1;
    await apiPacer.wait();
    const { items, hasMore } = await fetchAllTickets(page);
    return {
      changes: items.map((t) => ({
        type: "upsert" as const,
        key: t.id,
        properties: {
          Summary: Builder.title(t.summary),
          "Ticket ID": Builder.richText(t.id),
        },
      })),
      hasMore,
      nextState: hasMore ? { page: page + 1 } : undefined,
    };
  },
});
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 ticketsBackfill
ntn 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.

Relate two databases

Link databases together with Schema.relation() and Builder.relation():
const projects = worker.database("projects", {
  type: "managed",
  initialTitle: "Projects",
  primaryKeyProperty: "Project ID",
  schema: {
    properties: {
      Name: Schema.title(),
      "Project ID": Schema.richText(),
    },
  },
});

const tasks = worker.database("tasks", {
  type: "managed",
  initialTitle: "Tasks",
  primaryKeyProperty: "Task ID",
  schema: {
    properties: {
      Name: Schema.title(),
      "Task ID": Schema.richText(),
      Project: Schema.relation("projectsSync", {
        twoWay: true,
        relatedPropertyName: "Tasks",
      }),
    },
  },
});

worker.sync("projectsSync", {
  database: projects,
  execute: async () => { /* ... */ },
});

worker.sync("tasksSync", {
  database: tasks,
  execute: async () => {
    const items = await fetchTasks();
    return {
      changes: items.map((task) => ({
        type: "upsert" as const,
        key: task.id,
        properties: {
          Name: Builder.title(task.name),
          "Task ID": Builder.richText(task.id),
          Project: [Builder.relation(task.projectId)],
        },
      })),
      hasMore: false,
    };
  },
});
In the example above, the twoWay: true option adds a “Tasks” rollup column to the Projects database automatically.

Rate-limit outbound requests

Use a pacer to avoid hitting third-party API rate limits:
const api = worker.pacer("api", { allowedRequests: 10, intervalMs: 1000 });

worker.sync("customersSync", {
  database: customers,
  execute: async (state) => {
    await api.wait();
    const data = await fetchCustomers(state?.cursor);
    // ...
  },
});
await api.wait() blocks until a request slot is available. In this example, at most 10 requests per second.

Manage syncs from the CLI

# Live-updating status dashboard
ntn workers sync status

# Preview output without writing to the database
ntn workers sync trigger <syncKey> --preview

# Trigger a real sync immediately
ntn workers sync trigger <syncKey>

# Reset sync state (restart from scratch)
ntn workers sync state reset <syncKey>

# Pause a sync
ntn workers capabilities disable <syncKey>

# Resume a sync
ntn workers capabilities enable <syncKey>
Deploying does not reset sync state. Syncs resume from their last cursor position. See Resetting and migrating state below.

Reset and migrate state

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:
ntn workers sync state get <syncKey>

Troubleshooting syncs

Sync runs but no rows appear

  • 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.

Rows are duplicated

  • 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.

Stale rows aren’t deleted (replace mode)

  • 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).

Sync is stuck or out of date

  • Run ntn workers sync status to see the current state and last run time.
  • If state is corrupted or outdated, reset it with ntn workers sync state reset <syncKey>.

Checking logs

List recent runs:
ntn workers runs list
View execution logs for a specific run:
ntn workers runs logs <runId>