The voting service combines browser pages and JSON endpoints on one origin. Routes do not share an /api prefix.
| Environment | Base URL |
|---|---|
| Production | https://vote.ihnyc-rc.org |
| Local Wrangler | http://localhost:8787 |
Send Content-Type: application/json with JSON requests. There is no version segment in the current route scheme.
Authentication
Public election pages use invitation or ballot tokens where required. Every /admin/* route passes through the same admin authentication middleware.
Admin authentication accepts:
- a Cloudflare Access request with
CF-Access-JWT-AssertionorCF-Access-Authenticated-User-Email; or Authorization: Bearer <ADMIN_API_KEY>orAuthorization: <ADMIN_API_KEY>.
The Access policy at the edge is responsible for validating the Access session before these headers reach the Worker. Local requests to localhost, 127.0.0.1, and *.dev bypass admin authentication.
Authorization: Bearer <ADMIN_API_KEY>
Content-Type: application/jsonSee Authentication for the complete trust model.
Public routes
| Method | Route | Response | Purpose |
|---|---|---|---|
GET | /health | JSON | Service name, status, and version |
GET | / | HTML | Published elections and results |
GET | /e/:id | HTML or JSON | Election detail; JSON when Accept: application/json |
GET | /e/:id/vote | HTML | Simple or ranked ballot page |
POST | /e/:id/vote | JSON | Submit a simple ballot; the ranked branch is currently unavailable |
GET | /e/:id/vote/poll | HTML | Poll ballot page |
POST | /e/:id/vote/poll | JSON | Submit a single- or multiple-choice poll ballot |
GET | /e/:id/results | Redirect or JSON | Legacy HTML route redirects to /e/:id; JSON returns result data |
GET | /vote/my-elections | HTML | Batch-invite election list from email and token query values |
Content negotiation
GET /e/:id and GET /e/:id/results return JSON only when the Accept header contains application/json. Browser requests receive HTML; the legacy results page redirects to the combined election page.
GET /e/elec_123/results HTTP/1.1
Host: vote.ihnyc-rc.org
Accept: application/jsonThe results response includes results_mode: "computed" or "manual". Manual results also include the source payload, computed display form, and certification metadata.
Admin browser surfaces
These authenticated GET routes render the operator interface:
| Route | Surface |
|---|---|
/admin | Election dashboard |
/admin/elections/new | New election form |
/admin/elections/:id | Election detail and controls |
/admin/elections/:id/results-preview | Results preview; also returns JSON with Accept: application/json |
/admin/audit-logs | Audit log |
/admin/distribution-lists | Distribution-list editor; JSON when Accept: application/json |
/admin/bulk-invites | Multi-election invite form |
Current GET behavior
The browser router is registered before the admin JSON router.
GET /admin/elections/:idandGET /admin/audit-logstherefore resolve to HTML surfaces rather than their later JSON handlers. The distribution-list collection handles this explicitly through content negotiation.
Admin JSON routes
Elections and results
| Method | Route | Purpose |
|---|---|---|
POST | /admin/elections | Create an election or poll |
GET | /admin/elections | List elections |
PATCH | /admin/elections/:id/dates | Change opening and closing times |
PATCH | /admin/elections/:id/settings | Merge election settings |
PATCH | /admin/elections/:id/attachments | Set or clear one HTTPS attachment URL |
PUT | /admin/elections/:id/manual-results | Store imported or certified results |
DELETE | /admin/elections/:id/manual-results | Return to computed results |
POST | /admin/elections/:id/close | Close an election |
DELETE | /admin/elections/:id | Delete an election |
GET | /admin/elections/:id/published-status | Inspect home-page publication state |
Tokens, candidates, and options
| Method | Route | Purpose |
|---|---|---|
POST | /admin/elections/:id/tokens | Generate 1–1,000 single-use ballot tokens |
GET | /admin/elections/:id/tokens | List token batches and used/unused totals |
DELETE | /admin/elections/:id/batches | Delete token batches |
POST | /admin/elections/:id/candidates | Add a ranked-election candidate |
GET | /admin/elections/:id/candidates | List candidates |
POST | /admin/elections/:id/options | Add a poll option before ballots exist |
GET | /admin/elections/:id/options | List options and whether they remain editable |
DELETE | /admin/elections/:id/options/:optionId | Delete an option before ballots exist |
Generated plaintext tokens are returned once. The service stores SHA-256 token hashes and uses a Durable Object to validate and mark a token used before recording a ballot.
Invitations and email
| Method | Route | Purpose |
|---|---|---|
POST | /admin/elections/:id/invite | Send or queue individual or batch-mode invitations |
GET | /admin/elections/:id/invites | List invitation delivery state |
POST | /admin/elections/:id/invites/:inviteId/resend | Resend one invitation |
POST | /admin/elections/:id/invites/:inviteId/remind | Send one reminder |
POST | /admin/elections/:id/reminders/send | Send eligible reminders for an election |
POST | /admin/elections/:id/results-email | Send result email |
POST | /admin/bulk-invites | Invite recipients to multiple elections |
POST | /admin/test-emails | Send selected diagnostic email templates |
Invitation routes require RESEND_API_KEY, FROM_EMAIL, and BASE_URL. Batch mode gives each recipient a magic link to /vote/my-elections; individual mode sends an election-specific ballot link.
Distribution lists
| Method | Route | Purpose |
|---|---|---|
GET | /admin/distribution-lists | List distribution lists with Accept: application/json |
GET | /admin/distribution-lists/:id | Read one list |
POST | /admin/distribution-lists | Create a list |
PUT | /admin/distribution-lists/:id | Update a list |
DELETE | /admin/distribution-lists/:id | Delete a list |
POST | /admin/distribution-lists/:id/emails | Add email addresses |
DELETE | /admin/distribution-lists/:id/emails/:emailId | Remove one address |
Notion integration
| Method | Route | Purpose |
|---|---|---|
GET | /admin/notion/databases | List allowlisted databases |
GET | /admin/notion/databases/:databaseId/pages | Search eligible pages |
GET | /admin/notion/pages/:pageId/validate | Validate required page properties |
POST | /admin/elections/:id/notion/link | Link an election to a Notion page |
DELETE | /admin/elections/:id/notion/link | Remove the link |
POST | /admin/elections/:id/notion/sync | Run an immediate synchronization |
Request bodies
Create an election
{
"title": "Community meeting schedule",
"description": "Choose one meeting date.",
"open_at": "2030-01-10T14:00:00Z",
"close_at": "2030-01-17T22:00:00Z",
"ballot_type": "POLL",
"settings": {
"poll_response_type": "single",
"hide_results_until_close": true,
"publish_results_to_home": true
},
"options": ["Tuesday", "Thursday"]
}| Field | Required | Rules |
|---|---|---|
title | Yes | Non-empty; at most 500 characters |
description | No | At most 5,000 characters |
open_at | Yes | ISO 8601 timestamp |
close_at | Yes | After open_at; must be future unless allow_past_close is true |
ballot_type | Yes | SIMPLE_TRIPLE, RANKED_CONDORCET, or POLL |
settings | No | Supports result visibility, home publication, and poll response type |
options | For POLL | At least two non-empty labels |
notion_link | No | Allowlisted database_id and valid page_id |
attachment_url | No | HTTPS URL, at most 2,000 characters |
invite_mode | No | individual or batch |
Success returns the created id:
{
"id": "elec_123",
"message": "Election created"
}Submit a ballot
Simple elections use POST /e/:id/vote:
{
"token": "<single-use token>",
"choice": "YES"
}Polls use POST /e/:id/vote/poll:
{
"token": "<single-use token>",
"selected_option": "option_2"
}For a multiple-choice poll, replace selected_option with a non-empty selected_options array.
Ranked JSON submission is currently unavailable
The handler runs simple-ballot validation before it branches to ranked behavior, then performs an initial ranked validation without the election’s candidate ids. A normal ranking-only request is rejected. The intended payload is
{ "token": "...", "ranking": ["candidate_2", "candidate_1"] }, with every candidate exactly once. Follow ihnyc-rc-vote issue #10 for the fix.
Update settings
PATCH /admin/elections/:id/settings expects a settings wrapper:
{
"settings": {
"hide_results_until_close": true,
"publish_results_to_home": true
}
}Generate tokens
{
"count": 100,
"batch_name": "January invitation batch"
}count must be between 1 and 1,000. batch_name is optional and limited to 200 characters.
Success returns the plaintext tokens once:
{
"tokens": ["<single-use token>"],
"batch_name": "January invitation batch",
"count": 1,
"warning": "Tokens shown once. Store securely."
}Store manual results
{
"certified_at": "2030-01-18T15:00:00Z",
"certified_by": "Election administrator",
"notes": "Imported from the certified record.",
"results": {
"contests": [
{
"label": "Meeting date",
"items": [
{ "label": "Tuesday", "votes": 42 },
{ "label": "Thursday", "votes": 37 }
]
}
]
}
}Every contest and item needs a non-empty label. Votes must be non-negative whole numbers.
Rate limits
Application limits are in-memory per Worker isolate and keyed by client IP:
| Scope | Limit | Applied to |
|---|---|---|
| General | 100 requests per minute | Every /e/* and /vote/* route |
| Vote | 5 requests per minute | POST /e/:id/vote in addition to the general limit |
| Admin | 50 requests per minute | Notion lookup endpoints; also declared on the shadowed JSON audit-log handler |
Allowed responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. A rejected request returns 429 with error and retryAfter.
Current poll limit
POST /e/:id/vote/pollcurrently receives the general limit but not the additional five-per-minute vote limit. This is also tracked in ihnyc-rc-vote issue #10.
Errors
JSON errors do not use one universal envelope. Most routes return error and sometimes message or errors; manual-result errors use { "ok": false, "error": "...", "code": "..." }.
| Status | Meaning |
|---|---|
400 | Invalid fields, election state, or ballot shape; malformed JSON handling varies by route |
401 | Missing admin authentication or invalid ballot token |
403 | Used token, closed election, or results hidden until close |
404 | Election, invite, list, page, or option not found |
429 | Application rate limit exceeded |
500 | Missing server configuration or unexpected failure |
Sources
Verified against ihnyc-rc-vote commit 2451aa1.
ihnyc-rc-vote/src/index.tsihnyc-rc-vote/src/routes/vote.tsihnyc-rc-vote/src/routes/results.tsihnyc-rc-vote/src/routes/admin-ui.tsihnyc-rc-vote/src/routes/admin.tsihnyc-rc-vote/src/middleware/auth.tsihnyc-rc-vote/src/middleware/rate-limit.tsihnyc-rc-vote/src/models/election.tsihnyc-rc-vote/src/models/ballot.tsihnyc-rc-vote/src/models/token.tsihnyc-rc-vote/src/models/manual-results.ts