Automatically sync your Beehiiv newsletter subscribers and post analytics into Notion databases. Run it once, schedule it, or run it in a container.
Every Beehiiv API field name and pagination behavior this tool relies on is verified against the official API reference and enforced at runtime with zod — a shape mismatch fails loudly instead of silently writing empty fields to Notion.
| Beehiiv Field | Notion Property | Type |
|---|---|---|
email |
Title | |
status |
Status | Select — validating / invalid / pending / active / inactive / needs_attention / paused |
created |
SubscribedAt | Date |
subscription_tier |
Tier | Select — free / premium |
utm_source |
UtmSource | Text |
utm_medium |
UtmMedium | Text |
utm_campaign |
UtmCampaign | Text |
tags (expanded) |
Tags | Multi-select |
id |
BeehiivId | Text (deduplication key) |
| Beehiiv Field | Notion Property | Type |
|---|---|---|
title |
Title | Title |
subtitle |
Subtitle | Text |
status |
Status | Select — draft / confirmed / archived |
publish_date |
PublishDate | Date |
web_url |
WebUrl | URL |
stats.email.recipients (expanded) |
TotalSent | Number |
stats.email.opens |
Opens | Number |
stats.email.open_rate |
OpenRate | Number (%) |
stats.email.clicks |
Clicks | Number |
stats.email.click_rate |
ClickRate | Number (%) |
stats.email.unsubscribes |
Unsubscribes | Number |
stats.web.views |
WebViews | Number |
stats.web.clicks |
WebClicks | Number |
id |
BeehiivPostId | Text (deduplication key) |
Stats are nested under stats.email / stats.web (not flat) and are only present when the request includes expand[]=stats, which this tool always sends. tags on a subscriber similarly requires expand[]=tags.
- Node.js ≥ 18 (or Docker)
- A Beehiiv account with API access
- A Notion account with an integration set up
git clone https://github.com/whybothercoding/beehiiv-notion-sync.git
cd beehiiv-notion-sync
npm install
npm run build-
Copy the env template
cp .env.example .env
-
Fill in your API keys
- Get your Beehiiv API key and Publication ID → docs/beehiiv-api-setup.md
- Set up your Notion integration → docs/notion-integration-setup.md
-
Run the setup command to create your Notion databases
node dist/index.js setup --parent-page-id YOUR_NOTION_PAGE_ID
-
Copy the printed database IDs into your
.envNOTION_SUBSCRIBERS_DB_ID=... NOTION_POSTS_DB_ID=... -
Run your first sync
node dist/index.js sync
Creates both Notion databases under a parent page. Run this once before your first sync.
beehiiv-notion-sync setup --parent-page-id <notion-page-id>Syncs data from Beehiiv to Notion. Runs both subscribers and posts by default.
beehiiv-notion-sync sync # sync both (default)
beehiiv-notion-sync sync --subscribers # subscribers only
beehiiv-notion-sync sync --posts # posts only
beehiiv-notion-sync sync --dry-run # fetch data but skip all Notion writes
beehiiv-notion-sync sync --force # re-write every record, bypassing the unchanged-record cache
beehiiv-notion-sync sync --concurrency 5 # override concurrent Notion requests (default: 3)
beehiiv-notion-sync sync --rate-limit-ms 500 # override delay between Notion requests (default: 350)
beehiiv-notion-sync sync --json # print a machine-readable summary instead of progress outputUnchanged-record skipping: every sync hashes each record's mapped Notion properties and caches it in a local state file (.sync-state.json by default, override with SYNC_STATE_FILE). On the next run, a record whose hash hasn't changed is skipped entirely — no Notion API call — instead of being re-written every time. Since Notion writes are the rate-limited, slow part of a sync (not the Beehiiv fetch), this is what makes repeat syncs of a large, mostly-static list fast. Use --force to bypass it, e.g. after a Notion schema change.
Runs the sync on a recurring schedule (configured by SYNC_INTERVAL_HOURS in .env). Performs an initial sync immediately on startup, then repeats. Ctrl+C (or SIGTERM) stops the cron schedule and waits for any in-progress sync to finish before exiting, rather than killing it mid-write.
beehiiv-notion-sync startcp .env.example .env # fill in your keys
docker compose up -d --buildThis runs start (the scheduler) in the background, restarting on failure, with the unchanged-record cache persisted to a named volume so it survives container restarts. To run a one-off sync instead of the scheduler:
docker compose run --rm beehiiv-notion-sync sync --dry-runSee docs/scheduling.md for instructions on running start persistently with pm2, nohup, or as a systemd service.
| Variable | Required | Description |
|---|---|---|
BEEHIIV_API_KEY |
Yes | Your Beehiiv API key |
BEEHIIV_PUBLICATION_ID |
Yes | Your publication ID (pub_...) |
NOTION_API_KEY |
Yes | Your Notion integration token (secret_...) |
NOTION_SUBSCRIBERS_DB_ID |
Yes (sync/start) | Notion database ID for subscribers |
NOTION_POSTS_DB_ID |
Yes (sync/start) | Notion database ID for posts |
SYNC_INTERVAL_HOURS |
No | Hours between scheduled syncs (default: 6) |
NOTION_CONCURRENCY |
No | Concurrent Notion write requests (default: 3) |
NOTION_RATE_LIMIT_MS |
No | Delay between Notion requests, in ms (default: 350) |
SYNC_STATE_FILE |
No | Path to the unchanged-record cache (default: .sync-state.json) |
| Property | Type | Notes |
|---|---|---|
| Title | Primary field | |
| Status | Select | See subscriber status values above — Notion auto-creates any not pre-seeded |
| SubscribedAt | Date | |
| Tier | Select | free / premium |
| UtmSource | Text | |
| UtmMedium | Text | |
| UtmCampaign | Text | |
| Tags | Multi-select | |
| BeehiivId | Text | Used for deduplication |
| Property | Type | Notes |
|---|---|---|
| Title | Title | Primary field |
| Subtitle | Text | |
| Status | Select | draft / confirmed / archived |
| PublishDate | Date | |
| WebUrl | URL | |
| TotalSent | Number | stats.email.recipients |
| Opens | Number | |
| OpenRate | Number | Percent format |
| Clicks | Number | |
| ClickRate | Number | Percent format |
| Unsubscribes | Number | |
| WebViews | Number | stats.web.views |
| WebClicks | Number | stats.web.clicks |
| BeehiivPostId | Text | Used for deduplication |
npm install
npm run build # compile TypeScript
npm run dev # run without building (ts-node)
npm test # run unit tests
npm run test:watch # run tests in watch mode
npm run test:coverage # run tests with a coverage report
npm run lint # lint with ESLint
npm run lint:fix # lint and auto-fix
npm run format # format with Prettier
npm run format:check # check formatting without writing
npm run typecheck # tsc --noEmitA pre-commit hook (Husky + lint-staged) runs lint/format on staged files and a full typecheck before every commit. CI (.github/workflows/ci.yml) runs the same checks plus the full test suite with coverage thresholds enforced, on Node 18/20/22.
MIT