Creating BetterVoting elections via the API — what works, what doesn't¶
Notes on create_bv_test_election.py, the script that creates BV test elections (and casts their ballots) through the REST API instead of the builder UI. Written up after a round of testing on 2026-07-05, so the limitation below isn't re-discovered later.
How the script authenticates¶
The BV backend requires asymmetric RS256 auth for API creation: the election carries a PEM public key in auth_key, and the request's custom_id_token is a JWT signed with the matching private key. The script mints a fresh keypair per run — self-consistent, so no real BV account credential is stored or used. (The old HS256 "secret == user id" recipe is stale.)
The election's owner_id is whatever the script's BV_USER_ID says. Set it to your real BetterVoting account id so the elections it creates show up in your /manage list — the default is now Adam's account (ea09e7c7-b00d-427a-bef8-32ade437d49d, "Admin1"). That id is not a secret; it is the owner_id in every frozen _bv_export.json in this repo.
What works¶
- Creation + ballot casting via
POST /API/ElectionsandPOST /API/Election/{id}/vote. - Public visibility: the election is live at
bettervoting.com/<id>, votes tabulate, and the results are exportable (Election + Ballots + Results) from the UI. /managelisting: withowner_id= your account, the election appears in My Elections & Polls and is searchable by title. (Before this fix, script elections were owned by a throwaway identity and were invisible there.)- Full-export download WITHOUT the UI (found 2026-07-23). The results page's "Download → Download JSON" file is reconstructible from three anonymous GETs — no login, no admin role:
| export section | endpoint | note |
|---|---|---|
Election |
GET /API/Election/{id} → .election |
config only on its own |
Ballots |
GET /API/Election/{id}/anonymizedBallots → .ballots |
public; the admin GET …/ballots returns 401 anonymously |
Results |
GET /API/ElectionResult/{id} → .results |
tabulated on demand — the election does not need to be closed |
tools_adam/fetch_bv_export.py assembles these into the house frozen-export shape ({"Election":…, "Ballots":…, "Results":…}). Verified against the UI-downloaded vqyqkr export: Election and Results byte-identical, Ballots identical up to order (ballot order always varied between UI downloads too). create_bv_test_election.py now calls it automatically after casting, so a freshly minted election lands in _demo_dropbox/ with its full export already frozen. Crash-case elections whose ElectionResult 500s (the STV sole-survivor pair) freeze with --without-results (Results: [] + a self-documenting _note).
Election descriptions render markdown — and only [text](url) links¶
The description sent at mint is not plain text. BetterVoting renders election and race descriptions through formatMarkdown() (packages/shared/src/utils/formatMarkdown.ts), which recognises exactly two things — **bold** and [text](url) — and sanitises everything else. Its link rule is a single regex, rLink = /\[([^\]]*?)\]\(([^)]*?)\)/. There is no bare-URL autolinker.
That is what makes the house backlink form load-bearing. [Full lesson & tabulation](https://…) becomes a real <a target="_blank">; the retired bare form, Full lesson & tabulation: https://…, ships as grey text a voter has to select and copy. 65 of the repo's frozen exports are permanently in that state, because a description cannot be edited after the create (see the /admin gate below).
BetterVoting says so on screen exactly once. The Election Description field on Admin Home carries the helper text Supports **bold** and [link text](url) formatting — and it is the last one standing: the hint was added under both Description fields, and the later RaceForm rewrite dropped it, so a race description takes the same markdown with nothing on the page saying so. Checked against the source 2026-08-08 — the string survives in ElectionDetailsForm.tsx and SendEmailDialog.tsx only.
Ballot-data export format — the precinct column¶
BetterVoting's Ballot Data export (the per-ballot CSV, Ballot Data - <title>-<id>.csv) has this shape: ballot_id, precinct, <Candidate1>, <Candidate2>, …. Two things worth knowing:
- There is always a
precinctcolumn (column B), part of BV's precinct-tagging /precinctFilteredElectionfeature. It is blank unless the election actually defines precincts — API-created elections don't, so every row's precinct is empty. It's harmless: the JSON→YAML importer ignores it (only the candidate columns +ballot_idmatter). - Ranked methods put a rank in each candidate cell (
1= top …0= unranked), and equal ranks are preserved — a tie likeAva=Bianca=Cedricexports as1,1,1,…. Confirmed on BV2140 (48hjkv): the exported ballots round-trip the tied ranks exactly, and BV'sRankedRobin.tstabulated them to the same winner/records as the LH engine. So BV both accepts equal-rank ballots on creation and counts ties the same way LH does.
Voter identity when casting, and ballot anonymity in the export¶
How a vote is attributed to a "voter": the temp_id cookie. POST /API/Election/{id}/vote carries a temp_id cookie, and BV keys the ballot to that value. It's how an open/anonymous poll enforces one ballot per voter and lets a voter change their vote (re-submitting with the same temp_id updates the existing ballot rather than adding a new one). In the browser this cookie is set for you; our scripts set it explicitly, so each distinct temp_id = a distinct voter. To cast N independent ballots you use N distinct temp_ids (create_bv_test_election.py uses f"{USER_ID}_voter{idx}"); reuse one and BV overwrites/ rejects it as the same voter re-voting. Caveat: temp_id is an arbitrary string the caller chooses — no real identity, freely settable via the API. That's why these open API polls are fine for demos but not secure for a real election (a real one uses authenticated voter credentials, not a self-set cookie).
The temp_id does NOT appear in the export — ballots are anonymous. A ballot record in the exported JSON looks like:
{ "ballot_id": "b-vmm2y3c2", "election_id": "kjhpg6", "precinct": null,
"votes": [ { "race_id": "0", "scores": [ {"candidate_id": "c-494", "score": 3}, … ] } ] }
| Field | In the export? | What it is |
|---|---|---|
ballot_id |
✅ | a random per-ballot handle BV assigns — not the voter/temp_id, not a person |
election_id, precinct, votes.scores |
✅ | which election, precinct label (usually null), and the 0–5 scores (+ any write_in_name) |
temp_id / voter_id / user_id / IP / email |
❌ absent | — |
So the temp_id is server-side only (dedup / vote-changing while voting); it is not written into the exported ballot. The tally export is a set of anonymous ballots — random ballot_id + scores, with nothing tying a ballot back to a voter. This is the good half of the secret-ballot property, and it complements the paper-side discussion in the paper-ballot demo (serials / E2E-V): distinguishing ballots while voting without letting the tally re-identify a voter. (One residual: ballot_id is a stable handle for that ballot, so an external "voter X → ballot_id Y" record could re-link them — but the export itself provides no such map.)
What does NOT work — the /admin gate (a real BV limitation)¶
You cannot administer an API-created election from the UI, even though you own it. Opening /<id>/admin returns "Only the users with admin access on the election can view this page."
What is behind that gate — the admin URL map¶
The seven entries in the admin sidebar (Sidebar.tsx), and which of them the gate actually costs you:
| Sidebar entry | URL | Behind the gate? |
|---|---|---|
| Admin Home — title, description, start/end times, Duplicate, Archive | /<id>/admin |
🔒 |
| Build Ballot | /<id>/admin/build_ballot |
🔒 |
| Manage Voters — the two questions that set the mode | /<id>/admin/voters |
🔒 |
| Settings | /<id>/admin/settings |
🔒 |
| Preview Ballot (draft) / Live Ballot | /<id> |
public |
| Preview Results (draft) / Live Results | /<id>/results |
public |
| Publish & Share | /<id>/admin/publish |
🔒 |
Two things the table makes plain. The two entries this repo actually uses are the two that are not admin pages — /<id> to vote and /<id>/results to read the count are public URLs the sidebar merely links to, which is why the gate has never blocked the mint → export → freeze pipeline. And the ballot and results labels flip with election.state: a draft says Preview, an open election says Live, same two URLs either way. (An eighth entry, Edit Election Roles → /<id>/admin/roles, appears only when the ELECTION_ROLES feature flag is on.)
This was tested directly, and the result is counter-intuitive — two elections with the same owner_id (my account):
| Election | how created | admin_ids |
/admin |
|---|---|---|---|
r4dqvd (BV2105) |
BV builder UI | null |
✅ full admin |
xb8r6v (throwaway) |
API script | [my account] |
❌ denied |
The election that works has admin_ids: null; the one that's denied explicitly lists my account in admin_ids. So BV's /admin authorization reads neither owner_id nor admin_ids from the election record — it uses a server-side role/permission binding (voterAuth.roles / permissions, empty on the API-created one) that only the authenticated (Keycloak) create flow writes. Setting admin_ids in the create payload persists in the record but is ignored for authorization.
Consequences for the test-case workflow:
- API-created elections are public, listable, and exportable — enough for the reproduce-and-freeze pipeline (create → export → reproduce in LH → freeze
_bv_export.json). - They are not UI-administrable from your real login: you cannot edit, close, rename, or delete them from the UI (no API endpoint either). Throwaways linger in
/manageuntil a BV admin with DB access removes them. - Don't bother setting
admin_idsin the payload — proven no-op for authz.
Orphans awaiting BV-admin DB cleanup (created via the API, undeletable by us):
| bvid | why orphaned |
|---|---|
9tgj9d, xb8r6v |
early throwaways, labeled "ZZZ DELETE ME" |
bwbc6d |
Pet-poll test, created before the Test ID was wired into the title (un-numbered) |
mw9kpp |
Pet-poll test, superseded — its public title carried the old trash delete test — junk prefix (since removed) |
9hmbg8 |
Scratch SNTV confirmation (Plurality, 2 winners → c, b) — junk title "wqefwefwe…"; proved BV multi-winner Plurality = SNTV, then discardable |
2jpcxd (BV2255) |
"One mark each: the traditional choose-one ballot, counted four ways" — over-built, not wrong. The three bullet ballots are correct in all four races (Plurality / STAR / IRV / RankedRobin, all → Ella), but the ask was for a plain single-race illustration of the traditional voting style, not a four-method line-up; the permanent title frames it as the latter. Superseded by BV2256 c8h3tb (one STAR race, same ballots). Lesson: when the ask is "an example OF a ballot style," one race is the deliverable — a method comparison is a different lesson, and titles can't be edited. |
6btm9k, g6x8b9, f2vtc9, xm93tw |
ballot_style_lab 07a/07b/08/09 minted with num_winners: 1 by mistake (a driver read the raw num_winners: key, but load_election normalizes it to seats:) — so titled "BV2240–2243 — …, 3/3/2/4 seats" but actually single-winner. Ballots + method are correct; only the seat count is wrong. Superseded by the correct multi-winner mints BV2244 9dx494 / BV2245 pmrq4q / BV2246 qdh9qp / BV2247 v9rhhr. (Lesson: when building a spec from a yaml via the engine, read el["seats"], not el["num_winners"].) |
Lesson (why the title guard exists): because API elections are public and permanent, the title must be right on the first create — there is no rename or delete. create_bv_test_election.py now (a) prepends only the BV<n> Test ID (no "trash/delete/test" junk), and (b) runs a pre-check that blocks junk/placeholder titles and reminds you the title is permanent + public. Set BV_ALLOW_JUNK_TITLE=1 only to override deliberately.
Ready-to-file BetterVoting GitHub issue¶
If BV should fix this, the reproduction and evidence below are a clean report (paste into a new issue at github.com/Equal-Vote/bettervoting):
Title: Election owner can't access /admin — admin authorization ignores owner_id / admin_ids
An election whose owner_id is my account appears in my /manage list, but opening /<id>/admin denies me: "Only the users with admin access on the election can view this page."
Repro: Create an election via POST /API/Elections with owner_id set to my account id (second test: also admin_ids: [my id]). It appears in /manage, but /<id>/admin denies admin access.
Evidence — two elections, same owner_id (my account):
| Election | created via | admin_ids |
/admin result |
|---|---|---|---|
| UI-created | builder UI | null |
✅ full admin |
| API-created | POST /API/Elections |
[my account] |
❌ denied |
The election that works has admin_ids: null; the one that's denied explicitly lists me in admin_ids. So /admin authorization depends on neither owner_id nor admin_ids; it appears to use a server-side role binding written only by the authenticated create flow (voterAuth.roles/permissions are empty on the API-created election).
Impact: I can see but not administer — edit, close, or delete — my own election. API-created elections can't be cleaned up from the UI.
Ask (any one of): honor owner_id/admin_ids for /admin authorization; or provide a "claim" path to bind an owned election to my account; or document the intended behavior so the API-creation path is usable end-to-end.
Related¶
- BV — BetterVoting (the live web app)
- The script + how to run it:
create_bv_test_election.py— tool guide - The BV-backed case workflow is documented in the repo's
CLAUDE.md(steps 3–4).