Developers
API reference
A small JSON API over HTTPS. Everything an election publishes is readable without a key, because a result nobody can fetch is a result nobody can check.
Base URL https://api.zerovote.app
Auth Authorization: Bearer <jwt> (organizer endpoints only)
Errors non-2xx responses carry {"error": "message"}Public endpoints
No authentication, no rate limit beyond the global one, and safe to poll. These are the endpoints to build on if you are settling a market, feeding a dashboard, or auditing a result.
/v/:slugElection status, options, counts, and results once revealed.
{
"name": "Board Election 2026",
"slug": "board-election-2026",
"status": "revealed", // draft | open | closed | revealed
"candidates": [{ "index": 0, "label": "Alice" }, ...],
"ballot_open": "2026-03-01T09:00:00Z",
"ballot_close": "2026-03-08T09:00:00Z",
"reveal_at": "2026-03-08T10:00:00Z",
"drand_round": 18452031,
"drand_chain_hash": "52db9ba7...",
"ballot_count": 47,
"voter_count": 60,
"revealed_at": "2026-03-08T10:00:04Z",
"results": {
"winner": "Alice",
"winner_index": 0,
"is_tie": false,
"totals": [{ "candidate": "Alice", "index": 0, "votes": 20 }, ...],
"total_ballots": 47,
"spoiled": 0,
"drand_round": 18452031,
"drand_signature": "8f2c..."
}
}results is null until the reveal. There is no early partial tally to fetch, because no party has one.
/v/:slug/resultsJust the tally. 404s before the reveal.
{ "status": "revealed",
"revealed_at": "2026-03-08T10:00:04Z",
"results": { ... } }/v/:slug/boardEvery sealed ballot and every signed credential request.
{
"event_slug": "board-election-2026",
"num_candidates": 3,
"merkle_root": "a41f...",
"ballots": [
{ "ballot_id": "0d9c...", "encrypted_vote": "base64...",
"nullifier": "5b2e...", "merkle_index": 0 }, ...
],
"registrations": [
{ "voter_pub_key": "hex...", "blinded_credential": "base64...",
"challenge": "hex...", "voter_signature": "hex..." }, ...
]
}Available while voting is open, since sealed ballots reveal nothing. Two invariants are worth asserting in your own code: ballots.length <= registrations.length, and every nullifier appearing exactly once.
/v/:slug/snapshotThe whole election in one document, formatted for the verifier.
The board plus everything needed to check it independently: drand_public_key, drand_round, merkle_root, and published_results — the last so a verifier can hold our announcement against its own recomputed tally rather than printing two sets of numbers and leaving you to compare them.
# the verifier will fetch this for you $ zerovote-verify --slug board-election-2026 --api https://api.zerovote.app # or pin it yourself, and pair it with the beacon for that round $ curl -s https://api.zerovote.app/v/board-election-2026/snapshot > election.json $ zerovote-verify --snapshot election.json --beacon beacon.json
/api/public-eventsPublicly listed elections, grouped by what happens to them next.
{ "live": [...], "upcoming_reveals": [...], "recent_results": [...] }Settling without us
If you need a result the instant it exists, don't poll our server — go to the source. The election commits to a drand round up front, so you can watch drand directly and decrypt the board yourself the moment the round lands. Our tally is the same computation over the same public inputs.
# 1. before the reveal: pin the board and the round curl -s https://api.zerovote.app/v/SLUG/snapshot > sealed.json # 2. watch drand for the round the election is bound to curl -s https://api.drand.sh/52db9ba7.../public/18452031 > beacon.json # 3. recompute — no ZeroVote involvement zerovote-verify --snapshot sealed.json --beacon beacon.json
Pinning the snapshot before the reveal is the strongest thing you can do as an integrator: it fixes the set of ballots while nobody can read them, so any later change to the board is something you can prove rather than suspect.
Webhooks
Set webhook_url when you create an election (it must be https://) and we POST to it once, at the reveal.
POST your-endpoint
Content-Type: application/json
X-ZeroVote-Signature: <hmac-sha256 hex>
{ "event": "election.revealed",
"event_id": "...", "event_slug": "...", "event_name": "...",
"revealed_at": "2026-03-08T10:00:04Z",
"results": { ... },
"snapshot_url": "https://api.zerovote.app/v/SLUG/snapshot",
"verify_url": "https://api.zerovote.app/v/SLUG/results" }Treat the webhook as a nudge, not as evidence
X-ZeroVote-Signature header, there is currently no per-integrator shared secret to verify it against, so it cannot yet prove the request came from us. Fetch snapshot_url and check the result yourself.Organizer API
Authenticated with a bearer token from POST /auth/login. An account is an organizer's identity and holds no keys — voters do not need one, and the key that signs a credential request is generated in the voter's browser against the invite it belongs to.
/auth/signupCreate an account.
{ "email": "you@example.com", "password": "..." }
→ { "token": "jwt", "user_id": "..." }Nothing cryptographic is stored against the account. There is no key to lose and no blob to decrypt, which is what makes the alternative below possible.
/auth/googleSign in with a Google ID token.
{ "id_token": "eyJ..." } // from Google Identity Services
→ { "token": "jwt", "user_id": "..." }The token is verified against Google's published signing keys, with the audience pinned to this deployment's client id and email_verified required. There is no separate signup: the account exists once Google has vouched for the address. Returns 400 when the server has no GOOGLE_CLIENT_ID configured.
/api/eventsCreate an election. Invites send immediately by default.
{ "name": "Board Election 2026",
"candidates": ["Alice", "Bruno", "Chidi"], // 2–100, ≤200 chars, distinct
"voters": ["a@example.com", "b@example.com"],
"group_ids": [],
"ballot_open": "2026-03-01T09:00:00Z", // ≥ now − 5min
"ballot_close": "2026-03-08T09:00:00Z", // > ballot_open
"reveal_at": "2026-03-08T10:00:00Z", // > ballot_close
"is_public": true,
"publish_now": true, // false → draft, nothing mailed
"webhook_url": "https://you.example.com/hook" }Ballots are single-choice: voters pick exactly one candidate, so two options sharing a label are rejected — the tally is reported per option and nothing downstream could say which of the two won. The reveal time can never change afterwards, since the ballots are encrypted to that drand round.
Omitting publish_now mails the invites as part of the same call. Passing false leaves the election in draft: nothing is mailed, no voter slots exist yet, and the roster comes back unmasked so it can be checked. Publish it with the call below, or delete it while it is still a draft.
/api/events/:id/publishMail the invites and open voting.
Creates one voter slot per address, mails every invite, and moves the election to open. Irreversible, and refused on an election that is already published. Invites name the organizer only when their address is verified.
Invite links are never returned
/api/events/:id/voters/:slot_id/resend instead./api/events/:id/voters/:slot_id/resendRe-mail one voter's invitation.
Sends the invite again to the address already on that slot, which cannot be changed. slot_id comes from the roster and opens nothing on its own. Refused once the voter has collected their credential, since the old link would no longer work.
/api/events/:idDelete a draft.
Drafts only. Once invites are out the election is a matter of public record, and deleting it would let an organizer erase a count people were relying on — so this returns 400 for anything past draft.
/api/events/:idOrganizer view: counts and roster.
Returns ballot_count, registered_count, voter_count, the Merkle root, and per-voter slots carrying a slot_id and a masked address — full addresses only while the election is a draft, so the roster can be proofread before anything is sent. registered means a credential was collected — not that a ballot was cast.
There is no has_voted field
registered and accept that it over-counts.The rest, briefly
/auth/login→ token, user_id/auth/meemail, email_verified/auth/request-resetmails a reset link — same reply whether or not the account exists/auth/reset{ token, password } → session/api/eventselections you created/api/my-invitationselections you can vote in/api/groupssave a reusable roster/api/groupslist groups/api/groups/:idgroup with members/api/groups/:idrename, add or remove members/api/groups/:iddelete a group/api/my-groupsgroups you belong toThe voting protocol
You probably shouldn't implement this yourself
/vote/:tokenBallot configuration for one voter.
{ "event_id": "...", "event_name": "...", "event_slug": "...",
"candidates": [...], "num_candidates": 3,
"blind_signing_public_key": "...",
"drand_public_key": "...", "drand_round": 18452031,
"ballot_close": "...", "already_registered": false,
"voter_pub_key": "hex" } // absent if the invite has no key yet/vote/:token/claimAttach a voting key to an invite that has none.
{ "public_key": "hex" } // Ed25519, 32 bytes
→ 204Skip this when voter_pub_keyis already present. Voters don't need an account, so an invite usually arrives with no key on it and the client generates one. The claim is idempotent for the same key and returns 409 for a different one: whoever claimed the slot keeps it, because allowing a takeover would let anyone who saw the link lock out the real voter.
/vote/:token/challengeGet a challenge to sign.
→ { "challenge": "hex", "expires_at": "..." }/vote/:token/credentialTrade a signed challenge for a blind signature.
{ "challenge": "hex",
"signature": "hex", // see the domain note below
"blinded_credential": "base64" }
→ { "blind_signature": "base64" }Signature domain
hex_decode(challenge) || base64_decode(blinded_credential) — the raw bytes of both, concatenated. Signing the challenge alone would let a server swap in its own blinded credential and have a voter unwittingly authorise it.Exactly one credential is issued per voter, ever. There is no reissue, so persist the unblinded credential before you attempt to submit — a client that loses it between these two calls has stranded that voter permanently.
/vote/submit/:event_idCast the ballot. No token, no account, no identity.
{ "credential": "base64",
"signature": "base64", // unblinded RSA signature
"msg_randomizer": "base64", // present for the randomized variant
"encrypted_vote": "base64" } // tlock(choice_index, drand_pk, round)
→ { "ballot_id": "...", "merkle_index": 12, "merkle_root": "...",
"merkle_proof": { ... }, "leaf_hash": "..." }This request is deliberately unauthenticated — a bearer token here would recreate exactly the link blind signing exists to destroy. 409 means the credential was already spent.
The response is the voter's receipt, and it is the only copy. We cannot look it up again, because we do not know which ballot is theirs. Store it client-side and verify merkle_proof before telling anyone the vote succeeded.
Rate limits and errors
/auth/*5 requests / minute / IP/vote/*60 requests / minute / IP400Malformed request, or a schedule that doesn't validate.401Missing, expired, or invalid bearer token.403Authenticated, but not this election's organizer or voter.404No such election, or results requested before the reveal.409Credential already spent, or email already registered.429Rate limited.503Database unreachable. Retry.Check an election yourself
The verifier is a single binary. Give it a slug and it will tell you whether the announced result is the real one.