Automatically syncs events from a Notion database to a Google Calendar. You fill in events on Notion; this script keeps Google Calendar up to date automatically, running every 30 minutes. Notion is the sole source of truth — all events flow one-way from Notion into Google Calendar only.
- Node.js v18 or later (
node --versionto check) - A Notion internal integration with read access to your database:
- Go to notion.so/my-integrations → New integration
- Copy the "Internal Integration Token"
- Open your Notion database →
...menu → Connections → add your integration
- A Google Cloud project with OAuth credentials:
- Go to console.cloud.google.com → New project
- Enable the Google Calendar API (APIs & Services → Library)
- Create OAuth 2.0 credentials (APIs & Services → Credentials → Create Credentials → OAuth client ID → Web application)
- Add
http://localhost:3000/auth/callbackas an Authorised redirect URI - Copy the Client ID and Client Secret
- Find your target Calendar ID in Google Calendar → Settings → your calendar → "Calendar ID"
# 1. Clone and install
git clone <repo-url>
cd notioncal-to-gcal
npm install
# 2. Configure environment
cp .env.example .env
# Edit .env and fill in all six values
# 3. Authorise Google Calendar (one-time only)
node scripts/setup-auth.js
# Open the printed URL in your browser and grant permission
# tokens.json will be saved automatically — stop the server with Ctrl+C
# 4. Start syncing
node index.jsThe app is containerised and runs on Dokploy (Docker) on our Oracle server.
Pushing to main auto-deploys via Dokploy's GitHub integration.
See DEPLOY.md for the full setup: environment variables, the
/app/data persistent volume, and the one-time token-seeding step.
Key points for the deployed container:
tokens.jsonandsync-state.jsonlive in a persistent volume at/app/data(set via theDATA_DIRenv var, which theDockerfiledefaults to/app/data). Locally,DATA_DIRis unset so both files stay in the project root as usual.- Losing that volume would drop the OAuth tokens (sync stops) and the state map (next run re-creates every event as a duplicate) — so it must persist.
Each sync run:
- Fetches every page from your Notion database (filtered — see
sync/fetchNotion.js) - Creates a Google Calendar event for any page that has never been synced
- Updates the Google event for any page that was already synced. If that
event was deleted in Google (status
cancelledor 404/410), it is recreated instead of silently patching a dead event. - Deletes Google events whose Notion pages were removed or archived
- Skips pages with no date set (logs a warning)
Events with an Event Start Time become timed events (Australia/Melbourne);
otherwise they are all-day events. Description is built from the Caption and
Registration Link fields; Venue maps to the event location.
The mapping between Notion page IDs and Google event IDs is stored in
sync-state.json (never committed to git).
Beyond Google Calendar, a Notion event can fan out to more destinations. All of
these are optional and off by default — if a sink's env vars are missing it is
inert (silently skipped, never crashes). Every fan-out sink only fires for pages
whose Announce checkbox (a new property on the events database) is ticked.
Two kinds of sink:
- Mirror sinks — kept exactly in sync with Notion every run (create / update / delete). Google Calendar, and Discord scheduled events.
- Announce-once sinks — post once, and only after a human approves. These are the socials: LinkedIn (company page), Facebook (page), Instagram (feed).
Social posts publish live, so a human gates them — implemented by polling, no bot gateway needed:
- Tick
Announceon an event → on the next run the bot posts a preview of the post to a private Discord channel (DISCORD_CONFIRM_CHANNEL_ID) and adds a ✅. One preview per platform, so you can approve Facebook while holding Instagram. - A human clicks ✅.
- A later run reads the reaction and publishes to that platform (up to ~30 min later). Once posted it is never touched again.
Discord scheduled events work today once the bot env vars are set. LinkedIn, Facebook, and Instagram are fully built but inert until API access is granted:
| Sink | Blocker before it can post |
|---|---|
| Discord events | none — bot needs Create Events + Manage Events + confirm-channel access |
| LinkedIn (page) | Community Management API approval + page-admin token |
| Facebook (page) | Meta App Review + Business Verification |
| Instagram (feed) | Meta App Review + Business Verification + a public image URL; no text-only posts |
When access lands, just add the env vars (see .env.example) — no code change.
Each social platform is one file in sync/platforms/ exporting
{ key, label, isConfigured, buildPreview, publish }, registered in the
SOCIAL_PLATFORMS list in sync/syncRunner.js. The announceOnce framework
(sync/announce.js) handles the ✅ approval flow for all of them.
All field translation logic lives in sync/mapFields.js — read a value off
page.properties[...] and assign it onto the googleEvent object. Existing
examples in that file cover title, timeline dates, start/end times, venue
(location), and the caption + registration link (description). Add a new field
by following the same pattern; no other files need changing.
index.js Entry point: load tokens, start cron scheduler
scripts/setup-auth.js One-time Google OAuth browser flow
auth/google.js OAuth2 client, token helpers
auth/notion.js Notion client
routes/auth.js Express routes for OAuth callback
auth/discord.js Discord REST helper (bot-token fetch wrapper)
sync/fetchNotion.js Paginated, filtered fetch from Notion database
sync/mapFields.js Notion page → Google Calendar event (edit to add fields)
sync/googleCalendar.js Google Calendar create / update / delete wrappers
sync/mapDiscord.js Notion page → Discord scheduled-event payload
sync/discordEvents.js Discord scheduled-event create / update / delete wrappers
sync/announce.js Announce-once framework + Discord ✅ approval loop
sync/platforms/*.js One file per social platform (linkedin/facebook/instagram)
sync/stateManager.js Load and save sync-state.json (nested; honours DATA_DIR)
sync/syncRunner.js Core sync orchestration
Dockerfile Container image (node:22-alpine)
.dockerignore Excludes secrets, state, node_modules from the image
DEPLOY.md Dokploy deployment guide