Every edit you make in Notion can land in Airtable automatically — no copy-paste, no manual exports, no data drift. If your team lives in Notion but your operations dashboard runs in Airtable, you already know the pain: the two fall out of sync within hours, someone gets the wrong status, and suddenly half your workday disappears into reconciliation. This guide shows you how to build an n8n workflow that polls your Notion database every 15 minutes and upserts each record straight into Airtable — new items get created, updated items get refreshed, and nothing gets duplicated.
Prefer to skip the setup? Grab the ready-made template → and be up and running in under 10 minutes.
What You’ll Build
- A schedule-triggered n8n workflow fires every 15 minutes — no manual intervention needed.
- The workflow fetches every page from your chosen Notion database and filters it down to only those edited in the last 15 minutes, so you’re never re-processing stale data.
- A Code node extracts the Notion page properties (title, status, priority, email, due date, notes, and tags) into a clean, flat object.
- The Airtable node upserts each record using the Notion page ID as the unique key — if the row already exists it updates it; if not it creates a new one.
- Your Airtable base stays in near-real-time sync with Notion, with a direct link back to each Notion page for quick reference.
How It Works — The Big Picture
The workflow is a single linear pipeline: schedule → fetch → filter → transform → upsert. The magic is in the upsert step, which uses the Notion page ID as a match key so it can tell the difference between a record it’s seen before and a brand-new one.
┌──────────────────────────────────────────────────────────────────────┐ │ AUTO-SYNC NOTION DATABASE → AIRTABLE │ │ │ │ [Every 15 Min] │ │ │ │ │ ▼ │ │ [Fetch All Notion Pages] ← polls your Notion database │ │ │ │ │ ▼ │ │ [Filter: Modified in Last 15 Min] ← skips unchanged records │ │ │ │ │ ▼ │ │ [Extract & Map Fields] ← Code node: flattens Notion properties │ │ │ │ │ ▼ │ │ [Upsert in Airtable] ← creates new rows OR updates existing ones │ │ matched by notion_page_id │ └──────────────────────────────────────────────────────────────────────┘
What You’ll Need
- n8n — self-hosted (v1.0+) or an n8n Cloud account
- Notion account — with a database you want to sync; free tier works
- Notion Integration — a connected integration with read access to your database (takes about 2 minutes to set up at notion.so/my-integrations)
- Airtable account — free tier works; you’ll need a base with a table that mirrors your Notion schema
- Airtable Personal Access Token — generated from your Airtable account settings with
data.records:writescope
Estimated build time: 30–45 minutes from scratch, or under 10 minutes with the ready-made template.
Building the Workflow — Step by Step
1 Every 15 Minutes (Schedule Trigger)
This node kicks off the entire workflow on a recurring schedule. It doesn’t pass any data downstream — it’s just the heartbeat.
In n8n, add a Schedule Trigger node and configure it like this:
- Set Trigger Interval to Minutes
- Set Minutes Between Triggers to
15 - Click Save
Tip: If your Notion database is high-volume (hundreds of edits per day), consider shortening the interval to 5 minutes. For low-activity databases, 30 or 60 minutes is perfectly fine and reduces API calls.
2 Fetch All Notion Pages (Notion node)
This node connects to your Notion database and retrieves all pages inside it. We’ll filter them down in the next step — here we just get everything so nothing slips through the cracks.
- Add a Notion node and connect your Notion credential (or create one using your integration’s Internal Integration Secret)
- Set Resource to Database Page
- Set Operation to Get Many
- In the Database ID field, paste the ID of your Notion database. You can find it in the database URL:
notion.so/workspace/THIS-LONG-ID?v=... - Enable Return All so n8n fetches every page, not just the first 100
After this node runs, each output item represents one Notion page. The raw data looks something like this:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"last_edited_time": "2026-04-04T09:47:00.000Z",
"url": "https://www.notion.so/a1b2c3d4e5f67890abcdef1234567890",
"properties": {
"Name": { "title": [{ "plain_text": "Q2 Marketing Campaign" }] },
"Status": { "select": { "name": "In Progress" } },
"Priority": { "select": { "name": "High" } },
"Email": { "email": "james.carter@gmail.com" },
"Due Date": { "date": { "start": "2026-04-15" } },
"Notes": { "rich_text": [{ "plain_text": "Coordinate with Emily on copy approval." }] },
"Tags": { "multi_select": [{ "name": "Marketing" }, { "name": "Q2" }] }
}
}
The Notion node returns properties in a deeply nested format. Your property names (Name, Status, etc.) must match exactly what they’re called in your Notion database. If you’re using different column names, you’ll update the Code node in Step 4 to match.
3 Filter: Modified in Last 15 Min (Filter node)
Without this filter, the workflow would re-upsert every record in your Notion database on every run — wasteful and slow. This node passes only the pages that were edited since the last trigger fired.
- Add a Filter node
- Add a condition with Left Value set to the expression:
={{ new Date($json.last_edited_time).getTime() }} - Set Operator to Greater Than or Equal
- Set Right Value to:
={{ Date.now() - 15 * 60 * 1000 }} - Make sure Value Type is set to Number for both sides
Tip: The expression Date.now() - 15 * 60 * 1000 computes the Unix timestamp for exactly 15 minutes ago. If your schedule trigger interval is different, replace 15 with your interval in minutes. Adding a small buffer (say, 16 minutes for a 15-minute schedule) ensures you never miss a record due to tiny timing drift.
4 Extract & Map Fields (Code node)
Notion’s property format is deeply nested — a simple title field is buried inside properties.Name.title[0].plain_text. This Code node flattens everything into a clean, Airtable-ready object with one pass.
- Add a Code node
- Set Mode to Run Once for Each Item
- Paste in the following JavaScript:
// Extract Notion page properties into flat, Airtable-friendly fields
const page = $input.item.json;
const props = page.properties ?? {};
// Helper: safely read any Notion property type
const get = (key, type) => {
const p = props[key];
if (!p) return '';
switch (type) {
case 'title': return p.title?.[0]?.plain_text ?? '';
case 'rich_text': return p.rich_text?.[0]?.plain_text ?? '';
case 'select': return p.select?.name ?? '';
case 'multi_select': return p.multi_select?.map(s => s.name).join(', ') ?? '';
case 'date': return p.date?.start ?? '';
case 'email': return p.email ?? '';
case 'number': return p.number ?? 0;
case 'checkbox': return String(p.checkbox ?? false);
case 'url': return p.url ?? '';
default: return '';
}
};
return {
json: {
notion_page_id: page.id,
Name: get('Name', 'title'),
Status: get('Status', 'select'),
Priority: get('Priority', 'select'),
Email: get('Email', 'email'),
Due_Date: get('Due Date', 'date'),
Notes: get('Notes', 'rich_text'),
Tags: get('Tags', 'multi_select'),
Last_Edited: page.last_edited_time,
Notion_URL: page.url,
}
};
After this node runs, each item is a clean, flat object ready to be written to Airtable:
{
"notion_page_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"Name": "Q2 Marketing Campaign",
"Status": "In Progress",
"Priority": "High",
"Email": "james.carter@gmail.com",
"Due_Date": "2026-04-15",
"Notes": "Coordinate with Emily on copy approval.",
"Tags": "Marketing, Q2",
"Last_Edited": "2026-04-04T09:47:00.000Z",
"Notion_URL": "https://www.notion.so/a1b2c3d4..."
}
Tip: If your Notion database has different property names or types, update the get('PropertyName', 'type') calls to match. The helper function supports all common Notion property types — just change the key and type string. Add or remove fields as needed for your own schema.
5 Upsert in Airtable (Airtable node)
This is where the sync actually happens. The Airtable Upsert operation is the key: it checks whether a record matching your specified field already exists, updates it if so, and creates a new one if not — all in a single API call.
- Add an Airtable node and connect your Airtable credential (Personal Access Token)
- Set Operation to Upsert
- Set Base to your Airtable base (select from the dropdown or paste the Base ID)
- Set Table to your sync table
- Under Fields to Match On, enter
notion_page_id— this is the unique key that tells Airtable whether to create or update - In the field mapping section, map each field from the Code node output to the corresponding Airtable column
{
"notion_page_id": "={{ $json.notion_page_id }}",
"Name": "={{ $json.Name }}",
"Status": "={{ $json.Status }}",
"Priority": "={{ $json.Priority }}",
"Email": "={{ $json.Email }}",
"Due_Date": "={{ $json.Due_Date }}",
"Notes": "={{ $json.Notes }}",
"Tags": "={{ $json.Tags }}",
"Last_Edited": "={{ $json.Last_Edited }}",
"Notion_URL": "={{ $json.Notion_URL }}"
}
The Airtable upsert operation requires your Airtable table to have a text field named notion_page_id. If this field doesn’t exist, Airtable won’t know what to match on and will create duplicates. Create the field in Airtable before activating the workflow.
The Airtable Table Schema
Your Airtable sync table needs to match the fields your Code node outputs. Here’s the recommended schema — create these columns in Airtable before running the workflow for the first time:
| Column Name | Airtable Field Type | Example Value | Purpose |
|---|---|---|---|
notion_page_id |
Single line text | a1b2c3d4-e5f6-... |
Unique match key — never changes for a given Notion page |
Name |
Single line text | Q2 Marketing Campaign |
The page title from Notion |
Status |
Single line text (or Single select) | In Progress |
Current workflow status |
Priority |
Single line text (or Single select) | High |
Task priority level |
Email |
james.carter@gmail.com |
Contact email from Notion | |
Due_Date |
Date | 2026-04-15 |
ISO date string from Notion’s date property |
Notes |
Long text | Coordinate with Emily… |
Rich text notes (plain text only — no formatting) |
Tags |
Single line text | Marketing, Q2 |
Comma-separated list of Notion multi-select values |
Last_Edited |
Single line text | 2026-04-04T09:47:00Z |
ISO timestamp of last Notion edit — useful for debugging |
Notion_URL |
URL | https://notion.so/… |
Direct link back to the source Notion page |
Here’s what a couple of synced rows look like in practice:
| Name | Status | Priority | Due_Date | |
|---|---|---|---|---|
| Q2 Marketing Campaign | In Progress | High | james.carter@gmail.com | 2026-04-15 |
| Vendor Contract Review | Done | Medium | emily.rodriguez@outlook.com | 2026-03-31 |
| Onboarding Flow Redesign | Not Started | Low | michael.chen@gmail.com | 2026-05-01 |
Column names in Airtable are case-sensitive and must match exactly what the Code node outputs (e.g., Due_Date with a capital D and underscore). If you rename any column, update the Code node’s output keys to match.
Full System Flow
Here’s the complete data journey from Notion edit to Airtable row, end to end:
┌─────────────────────────────────────────────────────────────────────────┐
│ NOTION (source of truth) │
│ User edits a page → last_edited_time updates │
└──────────────────────────────┬──────────────────────────────────────────┘
│ (every 15 minutes)
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ n8n WORKFLOW │
│ │
│ Schedule Trigger (every 15 min) │
│ │ │
│ ▼ │
│ Notion node: GET all pages from database │
│ │ returns N page objects with nested properties │
│ ▼ │
│ Filter node: keep only pages where │
│ last_edited_time >= now - 15 min │
│ │ passes M ≤ N recently changed pages │
│ ▼ │
│ Code node: flatten Notion props → clean JSON object │
│ │ { notion_page_id, Name, Status, Priority, … } │
│ ▼ │
│ Airtable Upsert: match on notion_page_id │
│ ├─ Record found? → UPDATE existing row │
│ └─ Record missing? → CREATE new row │
│ │
└──────────────────────────────┬──────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ AIRTABLE (reporting / ops layer) │
│ Rows stay in sync with Notion — max 15 minutes behind │
└─────────────────────────────────────────────────────────────────────────┘
Testing Your Workflow
- Make a test edit in Notion: Open your Notion database, pick any page, and change a field (e.g., update the Status to “In Review”). Save it — Notion updates
last_edited_timeautomatically. - Run the workflow manually: In n8n, click Test Workflow (the play button) on the Schedule Trigger node to fire it immediately without waiting 15 minutes.
- Check the Filter node output: The edited page should pass through. If the Filter passes 0 items, check that your system clock is correct and that the edited page’s
last_edited_timeis recent. - Check Airtable: Within a few seconds, the corresponding row should appear or update in your Airtable table. Verify the field values match what you changed in Notion.
- Test the upsert (no-duplicate check): Run the workflow a second time immediately. The Airtable row should update in place — no duplicate rows should appear.
| Problem | Likely Cause | Fix |
|---|---|---|
| Filter passes 0 items even after editing Notion | Edit was made more than 15 minutes ago | Edit the page again and immediately run the test, or temporarily widen the filter window to 60 minutes |
| Airtable creates duplicate rows | notion_page_id column missing in Airtable table, or the “Fields to Match On” setting is blank |
Create the notion_page_id column in Airtable and confirm it’s set as the upsert match key |
| Code node fails with “Cannot read properties of undefined” | Your Notion database has a different property name or type than expected | Open the Notion node output in n8n and check the exact property names in properties, then update the Code node’s get() calls to match |
| Notion node returns 0 items | Integration not connected to the database | In Notion, open the database → click ⋯ → Connections → add your integration |
| Airtable “Invalid permissions” error | Personal Access Token missing data.records:write scope |
Regenerate the token at airtable.com/account with the correct scopes checked |
Frequently Asked Questions
Does this sync work in both directions — Airtable to Notion as well?
The workflow described here is one-directional: Notion is the source of truth and Airtable is the destination. Building a reverse sync (Airtable → Notion) is possible but requires a second workflow that watches Airtable for changes via polling or a webhook. Combining both directions into a bidirectional sync also requires a “last-write-wins” or conflict resolution strategy to avoid infinite loops.
What happens if my Notion database has hundreds of pages?
The Notion node with Return All enabled will fetch every page regardless of count, which can be slow for very large databases (500+ pages). In that case, consider using Notion’s built-in filter inside the Notion node to retrieve only pages modified after a certain date — this offloads the filtering to Notion’s API and reduces the volume of data n8n has to process.
Can I sync multiple Notion databases to multiple Airtable tables?
Yes — you can either duplicate this workflow (one copy per database-table pair) or extend it with a Switch node to route different database IDs to different Airtable table IDs. The duplicate approach is simpler to maintain; the Switch approach reduces the number of active workflows in your n8n instance.
My Notion database has properties that aren’t in the Code node — how do I add them?
Add a new line to the return { json: { ... } } block using the get('YourPropertyName', 'type') helper. The supported types are: title, rich_text, select, multi_select, date, email, number, checkbox, and url. Then create a matching column in your Airtable table and add it to the Airtable node’s field mapping.
Will this workflow work on n8n Cloud or only on self-hosted?
It works on both. The Schedule Trigger, Notion node, Filter node, Code node, and Airtable node are all built into n8n — no extra packages or server access required. On n8n Cloud, just import the JSON template and connect your credentials.
How do I handle Notion relation or formula properties?
Relation properties return an array of page references (not plain text), and formula properties return a computed value in a nested formula object. Neither is handled by the default Code node in this template. You can extend the get() helper with a case 'relation' branch that maps the array to a comma-separated list of page IDs, or use a case 'formula' branch that reads p.formula.string (or .number, .boolean) depending on your formula’s return type.
🚀 Get the Notion → Airtable Sync Template
You now know exactly how this workflow operates. Skip the setup time and grab the ready-to-import template — it includes the workflow JSON, a step-by-step Setup Guide PDF, and a Credentials Guide that walks you through every API key you’ll need.
Instant download · Works on n8n Cloud and self-hosted · $14.99
What’s Next?
- Add a Slack notification: After the Airtable upsert, add a Slack node that posts a message to a channel whenever a high-priority record is synced — good for keeping the team aware of urgent changes without checking Airtable manually.
- Filter by status: Add an IF node after the Code step to only sync records that are “In Progress” or “Done” — useful if you want Airtable to act as a completed-work log rather than a full mirror.
- Reverse sync: Build a companion workflow that polls Airtable for changes and writes them back to Notion, creating a true bidirectional sync between both tools.
- Add error alerting: Wrap the Airtable upsert in a try/catch (or use n8n’s Error Workflow feature) to send yourself an email or Slack DM if the sync fails — so you’re never left with stale data and no idea why.