An election can link to one page in an allowlisted Notion database. The voting Worker owns election timing and writes status to Notion. Notion supplies selected editorial metadata to the voting service.

Data flow

flowchart LR
  VOTE["Voting Worker"] -->|"Voting Status<br>Results Link"| NOTION["Linked Notion page"]
  NOTION -->|"Name<br>Description<br>Attachments<br>page URL"| VOTE
  VOTE <--> D1[("D1 elections table")]
  CRON["Cron · every minute"] --> VOTE
DirectionDataTiming
Voting service → NotionVoting StatusEvery scheduled run and manual sync
Voting service → NotionResults LinkLink time, plus scheduled sync for open and closed elections, when BASE_URL is configured
Notion → voting serviceName, Description, Attachments, page URLScheduled pull at most once per minute; manual sync on demand

The election clock is authoritative. A scheduled run replaces a manual Notion status edit with the state derived from open_at and close_at.

Fixed Notion properties

The current implementation uses fixed property names.

PropertyNotion typeRequiredVoting-service field
Voting StatusStatusYesComputed election state
Results LinkURLRequired for link writes when BASE_URL is setLegacy result URL
NameTitle or rich textNoelections.title
DescriptionRich text or textNoelections.description
AttachmentsFiles and mediaNoelections.attachments_json

Voting Status must define all three option names exactly:

Election stateStatus value
Before open_atNot started
From open_at until close_atIn Voting
At or after close_atDone

Custom property names and custom status values are not wired into the current admin routes.

Configuration

BindingPurpose
NOTION_API_KEYNotion integration token
NOTION_DATABASE_ALLOWLISTDatabases available to the admin UI and link routes
BASE_URLOrigin used to build {BASE_URL}/e/{election_id}/results

Set secrets with Wrangler:

wrangler secret put NOTION_API_KEY
wrangler secret put NOTION_DATABASE_ALLOWLIST

The allowlist accepts a JSON array:

[
  {
    "id": "<database-id>",
    "name": "Council elections"
  }
]

It also accepts comma-separated id:name pairs. The Worker returns an empty database list when the allowlist is absent.

The repository config runs the scheduled handler every minute:

{
  "triggers": {
    "crons": ["* * * * *"]
  }
}

Admin routes

All routes use the voting service’s admin authentication boundary.

MethodRouteBehavior
GET/admin/notion/databasesReturn parsed allowlist entries
GET/admin/notion/databases/:databaseId/pagesQuery up to 20 pages from an allowlisted database
GET/admin/notion/pages/:pageId/validateCheck the fixed Voting Status property and return selected page metadata
POST/admin/elections/:id/notion/linkValidate and link one page, then write the current state
DELETE/admin/elections/:id/notion/linkClear the stored Notion link
POST/admin/elections/:id/notion/syncRun one write-back and pull-in cycle

The three lookup routes receive the application-level admin rate limit. See Rate limiting.

GET /admin/notion/databases/:databaseId/pages accepts:

QueryMeaning
queryOptional title filter
cursorOptional Notion pagination cursor

The response contains pages and next_cursor. The current filter targets a Notion property literally named title; it does not discover a differently named title property such as Name.

Validation

GET /admin/notion/pages/:pageId/validate ignores a property_name query value and always checks Voting Status. It checks for In Voting and Done, then returns Name, Description, attachment metadata, and the page URL when validation succeeds.

The link route performs a stricter check and also requires Not started.

POST /admin/elections/elec_123/notion/link
Content-Type: application/json
{
  "database_id": "<allowlisted-database-id>",
  "page_id": "<notion-page-id>"
}

Only database_id and page_id are used. A successful response has this shape:

{
  "success": true,
  "notion_page_url": "https://www.notion.so/...",
  "status": "In Voting"
}

The route stores the page URL, writes the current status, and writes Results Link when BASE_URL is available.

Manual sync

POST /admin/elections/elec_123/notion/sync

The response separates the two directions:

{
  "success": true,
  "writeBack": {
    "status": "In Voting"
  },
  "pullIn": {
    "title": "Community meeting schedule"
  },
  "last_sync_at": 1785038400000
}

pullIn contains only changed values and may be absent. Attachment changes appear there as attachments.

Scheduled synchronization

When NOTION_API_KEY is configured, each scheduled run:

  1. loads every election with a Notion page id;
  2. writes Done to every closed linked election;
  3. writes Not started or In Voting to each remaining linked election;
  4. writes a results URL for open and closed elections when BASE_URL exists;
  5. pulls Name, Description, Attachments, and the canonical page URL when at least one minute has passed since the last pull; and
  6. records push, pull, and error timestamps in D1.

Write-back is not gated by the last-sync timestamp. The one-minute gate applies only to pull-in.

The legacy result URL remains /e/:id/results. Browser requests redirect to the combined election page at /e/:id; JSON clients can still request result data from the legacy route.

Closed elections also have request-time backstops in the admin election page and legacy results route. If a closed linked election has not recorded notion_done_set_at, those routes attempt the Done write.

Persistence

Notion state is stored with the election:

ColumnPurpose
notion_database_idSelected allowlist entry
notion_page_idLinked page
notion_page_urlCanonical page URL
notion_status_property_nameStored status-property name; current routes use Voting Status
notion_status_value_in_votingStored open value; current routes default to In Voting
notion_status_value_doneStored closed value; current routes default to Done
notion_last_sync_atLatest push or pull timestamp
notion_last_push_atLatest successful write-back
notion_last_pull_atLatest completed pull
notion_last_sync_errorLast recorded Notion error
notion_done_set_atFirst recorded closed-state write
attachments_jsonSerialized Notion file metadata

Unlinking clears the stored database, page, URL, and status-property name. It does not modify or delete the Notion page.

Current constraints

  • The link handler checks that database_id is allowlisted but does not compare it with the selected page’s actual parent database.
  • The validation endpoint can approve a page without Not started; the link route then rejects it.
  • For an upcoming election, the link route writes Not started but its response currently reports status: "In Voting".
  • An empty Notion Description does not clear an existing election description because the D1 update helper treats null as “do not update.”
  • The schema contains notion_results_link_property_name, but current route and sync code use the literal Results Link.
  • Pull-in errors during manual sync are logged without failing the outer write-back response.

Sources

Verified against ihnyc-rc-vote commit 2451aa1.

  • src/index.ts
  • src/routes/admin.ts
  • src/routes/admin-ui.ts
  • src/routes/results.ts
  • src/utils/notion.ts
  • src/utils/db.ts
  • migrations/006_notion_integration.sql
  • migrations/015_add_notion_attachments.sql
  • migrations/016_add_notion_sync_timestamps.sql
  • wrangler.jsonc