ZeroVote

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.

GET/v/:slug

Election 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.

GET/v/:slug/results

Just the tally. 404s before the reveal.

{ "status": "revealed",
  "revealed_at": "2026-03-08T10:00:04Z",
  "results": { ... } }
GET/v/:slug/board

Every 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.

GET/v/:slug/snapshot

The 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
GET/api/public-events

Publicly 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

Two honest caveats. There are no retries — one attempt, ten second timeout — so a brief outage on your side means a missed notification; keep a poll or a drand watcher as the real trigger. And while the request carries an 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.

POST/auth/signup

Create 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.

POST/auth/google

Sign 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.

POST/api/events

Create 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.

POST/api/events/:id/publish

Mail 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

A voting link plus any keypair the caller generates is enough to cast that voter's ballot, so no response from this API contains one — not this call, not the roster. They go only to the address they were addressed to. If you are building your own delivery, you cannot: use /api/events/:id/voters/:slot_id/resend instead.
POST/api/events/:id/voters/:slot_id/resend

Re-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.

DELETE/api/events/:id

Delete 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.

GET/api/events/:id

Organizer 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

Not withheld — absent. The credential is blind-signed, so no record anywhere joins a voter to a ballot, and the query has no answer to return. Build reminder logic around registered and accept that it over-counts.

The rest, briefly

POST/auth/login→ token, user_id
GET/auth/meemail, email_verified
POST/auth/request-resetmails a reset link — same reply whether or not the account exists
POST/auth/reset{ token, password } → session
GET/api/eventselections you created
GET/api/my-invitationselections you can vote in
POST/api/groupssave a reusable roster
GET/api/groupslist groups
GET/api/groups/:idgroup with members
PUT/api/groups/:idrename, add or remove members
DELETE/api/groups/:iddelete a group
GET/api/my-groupsgroups you belong to

The voting protocol

You probably shouldn't implement this yourself

The steps below are what the browser client does. Getting a detail wrong — the signature domain especially — produces ballots the server accepts and the verifier later rejects, which is the worst possible failure: a voter who believes they voted and a count that disagrees. Use the WASM client unless you have a reason not to.
GET/vote/:token

Ballot 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
POST/vote/:token/claim

Attach a voting key to an invite that has none.

{ "public_key": "hex" }         // Ed25519, 32 bytes
→ 204

Skip 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.

POST/vote/:token/challenge

Get a challenge to sign.

→ { "challenge": "hex", "expires_at": "..." }
POST/vote/:token/credential

Trade a signed challenge for a blind signature.

{ "challenge": "hex",
  "signature": "hex",             // see the domain note below
  "blinded_credential": "base64" }
→ { "blind_signature": "base64" }

Signature domain

The Ed25519 signature must cover 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.

POST/vote/submit/:event_id

Cast 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 / IP
400Malformed 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.