Casting votes
How a voter actually votes - authenticate once against the process census, get the credential service to blind-sign a ballot per question, then relay the signed envelope. The SDK's voting package does the signing for you; the SaaS forwards the ballot without ever decoding it.
Casting a vote is the only step with client-side cryptography. Everything else in the API is a plain
REST call; here the voter signs their own ballot so the SaaS never sees how they voted. The SDK's
voting package, @vocdoni/api-voting, does that cryptography for you: it generates the ephemeral
key, builds and signs the protobuf vote envelope, and relays it. Start with it below; if you would
rather implement the signing yourself, the raw flow is at the
end of this page.
Because each question is its own on-chain election, a voter authenticates once against the process, then signs and relays a ballot per question they are eligible for.
The flow
- Authenticate once - the voter presents whatever the census requires: identity
authFieldsand/or a one-time code sent to theiremail/phone(a 2FA census needs no auth fields). Success yields a token bound to the process. - Blind-sign per question - for each question, the credential service (CSP) blind-signs the voter's ephemeral voting address for that question's election. It refuses unless the voter is in the question's eligibility subset. Signatures are salted per election, so one cannot be replayed on another question.
- Build and sign - the voter builds the protobuf vote envelope and signs it locally with the ephemeral key.
- Relay - the signed envelope is relayed (asynchronously) to the protocol, which returns a vote receipt (nullifier).
Steps 1 and 2 are plain REST calls, wrapped by the API client as client.processes; steps 3 and 4
are the cryptography @vocdoni/api-voting implements.
The voter-facing endpoints are public - they carry no API key. The voter only needs the
apiUrl, the processId and the Vochain chainId. The
process read that returns them is public
too, so the voter app fetches them directly - no backend handover needed; each question's
upstreamId is also reported by the check call below.
Cast a vote with the SDK
Install the voting package alongside the API client - the client covers the voter-facing REST calls, the voting package the cryptography:
npm install @vocdoni/api-client @vocdoni/api-voting
pnpm add @vocdoni/api-client @vocdoni/api-voting
yarn add @vocdoni/api-client @vocdoni/api-voting
client.processes covers the voter-facing calls (authenticate, check eligibility, get the CSP
signature) and VotingClient does the rest - one call builds the envelope, signs it with the
ephemeral key and relays it:
import { VocdoniApiClient } from '@vocdoni/api-client'
import { EphemeralSigner, VotingClient } from '@vocdoni/api-voting'
const client = new VocdoniApiClient({ apiUrl: 'https://saas-api-stg.vocdoni.net' })
const voting = new VotingClient({ client })
// Both reported by the (public) process read.
const processId = '<processId>'
const chainId = '<chainId>'
// 1. Authenticate once - send exactly the fields the census requires. An
// auth-only census (like this one) returns a verified token right away; a
// 2FA census confirms a one-time code first (see the note below).
const { authToken } = await client.processes.authStep0(processId, { memberNumber: 'A-101' })
// 2. Check where the voter stands - census membership plus, per question,
// eligibility and that question's on-chain election id (upstreamId).
const { belongsToProcess, questions } = await client.processes.check(processId, { authToken })
const question = questions.find((q) => q.canVote && !q.hasVoted)
if (!belongsToProcess || !question?.upstreamId) throw new Error('nothing to vote on')
// 3. Fresh ephemeral key per ballot; the CSP signs its address for this
// question's election. Refused unless the voter is in the question's
// eligibility subset.
const signer = new EphemeralSigner()
const { signature, weight } = await client.processes.sign(processId, {
authToken,
electionId: question.upstreamId,
payload: signer.address,
})
// 4. Build + sign the vote envelope locally, relay it, and poll for the receipt.
const jobId = await voting.vote({
processId: question.upstreamId, // the vote goes to the question's election
chainId,
choices: [1], // the ballot - see Voting types for its shape per ballot type
signer,
cspSignature: signature,
cspWeight: weight,
})
const job = await client.jobs.waitFor(jobId)
console.log('voteID:', job.result?.voteID)
Repeat steps 3 and 4 for every question the voter is eligible for - the auth token and the check
from steps 1 and 2 are reused across all of them, but each ballot needs a fresh
EphemeralSigner. The returned voteID is the vote nullifier - the voter's receipt, which
they can use to verify their vote was counted. Once a question ends, read its tally from
Results.
2FA censuses
When the census verifies voters by email/phone, step 0 sends a one-time code and returns a
pending token. Confirm it before signing:
client.processes.authStep1(processId, { authToken, authData: ['123456'] }). Need a new code?
client.processes.resend(processId, { authToken }).
Encrypted questions
A question created with secretUntilTheEnd keeps its ballots sealed until it ends. Fetch its
encryptionKeys from the public
question read and pass them to vote() -
the ballot is sealed automatically:
const { encryptionKeys } = await client.processes.getQuestion(processId, question.questionId)
const jobId = await voting.vote({
// ...same options as above, plus:
encryptionKeys,
})
The keykeepers publish the keys asynchronously, so encryptionKeys can be absent for a few seconds
right after publish - poll the question read until it is present before building the ballot.
Building with React
@vocdoni/react-providers wraps this whole flow in context providers and hooks that authenticate,
sign and relay for you. See the SDK repository and the
SDK quickstart.
Voter status
Public helpers let a UI show a voter where they stand without casting anything. Each identifies the
voter by their verified authToken:
curl -X POST "$B/processes/$PROCESS/check" -d '{ "authToken": "<authToken>" }'
# -> { "belongsToProcess": true, "weight": "1",
# "questions": [ { "questionId": "...", "upstreamId": "...", "canVote": true, "hasVoted": false } ] }
Looking up whether specific members voted is an admin task, not a voter one:
Doing the signing yourself
Not using the SDK - a non-JS client, or you want to audit what goes on the wire? Everything above
reduces to the REST calls below plus building and signing the envelope: generate an ephemeral
secp256k1 keypair, build the protobuf VoteEnvelope carrying the CSP (CA) proof, and sign the
transaction with an EIP-191 personal_sign signature. The
voting package source is the reference implementation.
# a) Authenticate (step 0) - send exactly the fields the census requires (authFields and/or the
# email/phone used for the code). Here an auth-only census by memberNumber; a mail census would
# send { "email": "[email protected]" }. Auth-only censuses are verified here and there is no code.
curl -X POST "$B/processes/$PROCESS/auth/0" \
-H "Content-Type: application/json" \
-d '{ "memberNumber": "A-101" }'
# -> { "authToken": "<authToken>" }
# 2FA censuses only: submit the emailed/SMS one-time code (authData[0] is the code).
# Need a new code? POST /processes/$PROCESS/auth/resend with the authToken.
curl -X POST "$B/processes/$PROCESS/auth/1" \
-H "Content-Type: application/json" \
-d '{ "authToken": "<authToken>", "authData": ["123456"] }'
# b) CSP blind-signs the ephemeral address for one question's election (electionId = its upstreamId).
# Refused unless the voter is in that question's eligibility subset.
curl -X POST "$B/processes/$PROCESS/sign" \
-H "Content-Type: application/json" \
-d '{ "authToken": "<authToken>", "electionId": "<upstreamId>", "payload": "<hex ephemeral address>" }'
# -> { "signature": "<csp-signature>", "weight": "1" }
# c) Build + sign the protobuf Vote envelope locally, hex-encode the SignedTx, then relay it (async).
curl -X POST "$B/vote" \
-H "Content-Type: application/json" \
-d '{ "txPayload": "<hex of the signed Vote envelope>" }'
# -> 202 Accepted { "jobId": "<jobId>" }
curl -s "$B/jobs/<jobId>"
# -> { "status": "completed", "result": { "voteID": "<nullifier>" } }
Repeat steps b and c for every question the voter is eligible for; the auth token from step a is reused across all of them.
What is in the envelope
The vote package inside the envelope is {"votes":[<choice>]} - for example {"votes":[1]}. Building
and signing the envelope is exactly what the SDK does for you above. See
Voting types for how the choices array is shaped per ballot type.
Encrypted (secret-until-the-end) questions
For a question created with secretUntilTheEnd, seal the vote package with the question's
encryptionKeys before building the envelope. Read them from the
question read: the field is absent until the
keykeepers publish the keys, so poll the question until encryptionKeys is present, then encrypt
with them.