The voting service combines browser pages and JSON endpoints on one origin. Routes do not share an /api prefix.

EnvironmentBase URL
Productionhttps://vote.ihnyc-rc.org
Local Wranglerhttp://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:

  1. a Cloudflare Access request with CF-Access-JWT-Assertion or CF-Access-Authenticated-User-Email; or
  2. Authorization: Bearer <ADMIN_API_KEY> or Authorization: <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/json

See Authentication for the complete trust model.

Public routes

MethodRouteResponsePurpose
GET/healthJSONService name, status, and version
GET/HTMLPublished elections and results
GET/e/:idHTML or JSONElection detail; JSON when Accept: application/json
GET/e/:id/voteHTMLSimple or ranked ballot page
POST/e/:id/voteJSONSubmit a simple ballot; the ranked branch is currently unavailable
GET/e/:id/vote/pollHTMLPoll ballot page
POST/e/:id/vote/pollJSONSubmit a single- or multiple-choice poll ballot
GET/e/:id/resultsRedirect or JSONLegacy HTML route redirects to /e/:id; JSON returns result data
GET/vote/my-electionsHTMLBatch-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/json

The 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:

RouteSurface
/adminElection dashboard
/admin/elections/newNew election form
/admin/elections/:idElection detail and controls
/admin/elections/:id/results-previewResults preview; also returns JSON with Accept: application/json
/admin/audit-logsAudit log
/admin/distribution-listsDistribution-list editor; JSON when Accept: application/json
/admin/bulk-invitesMulti-election invite form

Current GET behavior

The browser router is registered before the admin JSON router. GET /admin/elections/:id and GET /admin/audit-logs therefore 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

MethodRoutePurpose
POST/admin/electionsCreate an election or poll
GET/admin/electionsList elections
PATCH/admin/elections/:id/datesChange opening and closing times
PATCH/admin/elections/:id/settingsMerge election settings
PATCH/admin/elections/:id/attachmentsSet or clear one HTTPS attachment URL
PUT/admin/elections/:id/manual-resultsStore imported or certified results
DELETE/admin/elections/:id/manual-resultsReturn to computed results
POST/admin/elections/:id/closeClose an election
DELETE/admin/elections/:idDelete an election
GET/admin/elections/:id/published-statusInspect home-page publication state

Tokens, candidates, and options

MethodRoutePurpose
POST/admin/elections/:id/tokensGenerate 1–1,000 single-use ballot tokens
GET/admin/elections/:id/tokensList token batches and used/unused totals
DELETE/admin/elections/:id/batchesDelete token batches
POST/admin/elections/:id/candidatesAdd a ranked-election candidate
GET/admin/elections/:id/candidatesList candidates
POST/admin/elections/:id/optionsAdd a poll option before ballots exist
GET/admin/elections/:id/optionsList options and whether they remain editable
DELETE/admin/elections/:id/options/:optionIdDelete 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

MethodRoutePurpose
POST/admin/elections/:id/inviteSend or queue individual or batch-mode invitations
GET/admin/elections/:id/invitesList invitation delivery state
POST/admin/elections/:id/invites/:inviteId/resendResend one invitation
POST/admin/elections/:id/invites/:inviteId/remindSend one reminder
POST/admin/elections/:id/reminders/sendSend eligible reminders for an election
POST/admin/elections/:id/results-emailSend result email
POST/admin/bulk-invitesInvite recipients to multiple elections
POST/admin/test-emailsSend 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

MethodRoutePurpose
GET/admin/distribution-listsList distribution lists with Accept: application/json
GET/admin/distribution-lists/:idRead one list
POST/admin/distribution-listsCreate a list
PUT/admin/distribution-lists/:idUpdate a list
DELETE/admin/distribution-lists/:idDelete a list
POST/admin/distribution-lists/:id/emailsAdd email addresses
DELETE/admin/distribution-lists/:id/emails/:emailIdRemove one address

Notion integration

MethodRoutePurpose
GET/admin/notion/databasesList allowlisted databases
GET/admin/notion/databases/:databaseId/pagesSearch eligible pages
GET/admin/notion/pages/:pageId/validateValidate required page properties
POST/admin/elections/:id/notion/linkLink an election to a Notion page
DELETE/admin/elections/:id/notion/linkRemove the link
POST/admin/elections/:id/notion/syncRun 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"]
}
FieldRequiredRules
titleYesNon-empty; at most 500 characters
descriptionNoAt most 5,000 characters
open_atYesISO 8601 timestamp
close_atYesAfter open_at; must be future unless allow_past_close is true
ballot_typeYesSIMPLE_TRIPLE, RANKED_CONDORCET, or POLL
settingsNoSupports result visibility, home publication, and poll response type
optionsFor POLLAt least two non-empty labels
notion_linkNoAllowlisted database_id and valid page_id
attachment_urlNoHTTPS URL, at most 2,000 characters
invite_modeNoindividual 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:

ScopeLimitApplied to
General100 requests per minuteEvery /e/* and /vote/* route
Vote5 requests per minutePOST /e/:id/vote in addition to the general limit
Admin50 requests per minuteNotion 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/poll currently 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": "..." }.

StatusMeaning
400Invalid fields, election state, or ballot shape; malformed JSON handling varies by route
401Missing admin authentication or invalid ballot token
403Used token, closed election, or results hidden until close
404Election, invite, list, page, or option not found
429Application rate limit exceeded
500Missing server configuration or unexpected failure

Sources

Verified against ihnyc-rc-vote commit 2451aa1.

  • ihnyc-rc-vote/src/index.ts
  • ihnyc-rc-vote/src/routes/vote.ts
  • ihnyc-rc-vote/src/routes/results.ts
  • ihnyc-rc-vote/src/routes/admin-ui.ts
  • ihnyc-rc-vote/src/routes/admin.ts
  • ihnyc-rc-vote/src/middleware/auth.ts
  • ihnyc-rc-vote/src/middleware/rate-limit.ts
  • ihnyc-rc-vote/src/models/election.ts
  • ihnyc-rc-vote/src/models/ballot.ts
  • ihnyc-rc-vote/src/models/token.ts
  • ihnyc-rc-vote/src/models/manual-results.ts