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
| Direction | Data | Timing |
|---|---|---|
| Voting service → Notion | Voting Status | Every scheduled run and manual sync |
| Voting service → Notion | Results Link | Link time, plus scheduled sync for open and closed elections, when BASE_URL is configured |
| Notion → voting service | Name, Description, Attachments, page URL | Scheduled 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.
| Property | Notion type | Required | Voting-service field |
|---|---|---|---|
Voting Status | Status | Yes | Computed election state |
Results Link | URL | Required for link writes when BASE_URL is set | Legacy result URL |
Name | Title or rich text | No | elections.title |
Description | Rich text or text | No | elections.description |
Attachments | Files and media | No | elections.attachments_json |
Voting Status must define all three option names exactly:
| Election state | Status value |
|---|---|
Before open_at | Not started |
From open_at until close_at | In Voting |
At or after close_at | Done |
Custom property names and custom status values are not wired into the current admin routes.
Configuration
| Binding | Purpose |
|---|---|
NOTION_API_KEY | Notion integration token |
NOTION_DATABASE_ALLOWLIST | Databases available to the admin UI and link routes |
BASE_URL | Origin used to build {BASE_URL}/e/{election_id}/results |
Set secrets with Wrangler:
wrangler secret put NOTION_API_KEY
wrangler secret put NOTION_DATABASE_ALLOWLISTThe 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.
| Method | Route | Behavior |
|---|---|---|
GET | /admin/notion/databases | Return parsed allowlist entries |
GET | /admin/notion/databases/:databaseId/pages | Query up to 20 pages from an allowlisted database |
GET | /admin/notion/pages/:pageId/validate | Check the fixed Voting Status property and return selected page metadata |
POST | /admin/elections/:id/notion/link | Validate and link one page, then write the current state |
DELETE | /admin/elections/:id/notion/link | Clear the stored Notion link |
POST | /admin/elections/:id/notion/sync | Run one write-back and pull-in cycle |
The three lookup routes receive the application-level admin rate limit. See Rate limiting.
Page search
GET /admin/notion/databases/:databaseId/pages accepts:
| Query | Meaning |
|---|---|
query | Optional title filter |
cursor | Optional 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.
Link an election
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/syncThe 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:
- loads every election with a Notion page id;
- writes
Doneto every closed linked election; - writes
Not startedorIn Votingto each remaining linked election; - writes a results URL for open and closed elections when
BASE_URLexists; - pulls
Name,Description,Attachments, and the canonical page URL when at least one minute has passed since the last pull; and - 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:
| Column | Purpose |
|---|---|
notion_database_id | Selected allowlist entry |
notion_page_id | Linked page |
notion_page_url | Canonical page URL |
notion_status_property_name | Stored status-property name; current routes use Voting Status |
notion_status_value_in_voting | Stored open value; current routes default to In Voting |
notion_status_value_done | Stored closed value; current routes default to Done |
notion_last_sync_at | Latest push or pull timestamp |
notion_last_push_at | Latest successful write-back |
notion_last_pull_at | Latest completed pull |
notion_last_sync_error | Last recorded Notion error |
notion_done_set_at | First recorded closed-state write |
attachments_json | Serialized 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_idis 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 startedbut its response currently reportsstatus: "In Voting". - An empty Notion
Descriptiondoes not clear an existing election description because the D1 update helper treatsnullas “do not update.” - The schema contains
notion_results_link_property_name, but current route and sync code use the literalResults 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.tssrc/routes/admin.tssrc/routes/admin-ui.tssrc/routes/results.tssrc/utils/notion.tssrc/utils/db.tsmigrations/006_notion_integration.sqlmigrations/015_add_notion_attachments.sqlmigrations/016_add_notion_sync_timestamps.sqlwrangler.jsonc