Voting processes
A process is one authoring call that bundles shared settings, an inline census, and one or more questions - each question becomes its own on-chain election. Create it as a draft, publish it in one batch, then read results.
A process groups everything an election needs into a single object: shared settings (title, dates, header), an inline census, and one or more questions. Each question becomes its own on-chain election, so a process with three questions publishes three elections in one batch - no separate census setup, no per-question wiring.
You create a process as a draft (published: false), edit it freely, then publish it. One
processId identifies it for its whole life; each published question exposes its on-chain election
id as upstreamId (voters need it to sign; you never address the process by it).
Creating a process
POST /processes creates the draft and its inline census, and returns the processId. Titles and
descriptions are multilanguage strings.
| Field | Type | Description |
|---|---|---|
orgAddressआवश्यक |
string | The organization the process belongs to (0x…). |
censusआवश्यक |
object | Inline census - who can vote and how they authenticate. See Census. |
titleआवश्यक |
multilang | Process title, keyed by language with a default. |
description |
multilang | Longer description. |
startDateआवश्यक |
string (ISO 8601) | When voting opens. |
endDateआवश्यक |
string (ISO 8601) | When voting closes. |
header |
string | Optional banner image URL. |
streamUri |
string | Optional live-stream URL. |
initialStatus |
string | On-chain status the questions publish with: READY (the default, voting opens at startDate) or PAUSED (voting stays closed until you set each question to READY). See Publishing paused. |
questionsआवश्यक |
array | 1..N questions (see below). Each becomes one on-chain election. |
Each question shapes one ballot:
| Field | Type | Description |
|---|---|---|
titleआवश्यक |
multilang | Question title. |
description |
multilang | Question description. |
choicesआवश्यक |
array | Options, each a title plus a numeric value. One choice may set openValue: true to collect a free-text memo - see Open-value choices. |
type |
string | singlechoice, multichoice, ranked or cumulative. See Voting types. |
typeSetup |
object | Tuning for the type: maxChoices (multichoice), budget and costExponent (cumulative), minChoices. |
ballotProtocol |
object | Optional raw ballot override for shapes the named types do not cover. Takes priority over type/typeSetup. |
census |
object | Optional eligibility subset (groupId/memberIds) within the process census. Omit to include all census members. |
secretUntilTheEnd |
boolean | Keep this question's tally encrypted until it ends. |
# draft created, published:false
PROCESS=$(curl -s "${auth[@]}" -X POST "$B/processes" -d @- <<JSON | jq -r .processId
{
"orgAddress": "$ORG",
"census": { "authFields": ["memberNumber"] },
"title": { "default": "Board election 2027" },
"description": { "default": "Elect the new board" },
"startDate": "2027-07-01T09:00:00Z",
"endDate": "2027-07-03T18:00:00Z",
"questions": [
{
"title": { "default": "Who should chair the board?" },
"choices": [
{ "title": { "default": "Ada Lovelace" }, "value": 0 },
{ "title": { "default": "Alan Turing" }, "value": 1 }
],
"type": "singlechoice"
}
]
}
JSON
)
// draft created, published:false - resolves to the processId
const processId = await client.elections.create({
orgAddress: org,
census: { authFields: ['memberNumber'] },
title: 'Board election 2027', // plain strings become { default: ... }
description: 'Elect the new board',
startDate: '2027-07-01T09:00:00Z',
endDate: '2027-07-03T18:00:00Z',
questions: [
{
title: 'Who should chair the board?',
choices: [
{ title: 'Ada Lovelace', value: 0 },
{ title: 'Alan Turing', value: 1 },
],
type: 'singlechoice',
},
],
})
var processId = (await Post("/processes", new {
orgAddress = org,
census = new { authFields = new[] { "memberNumber" } },
title = new { @default = "Board election 2027" },
startDate = "2027-07-01T09:00:00Z", endDate = "2027-07-03T18:00:00Z",
questions = new[] { new {
title = new { @default = "Who should chair the board?" },
choices = new[] { new { title = new { @default = "Ada Lovelace" }, value = 0 },
new { title = new { @default = "Alan Turing" }, value = 1 } },
type = "singlechoice",
}}})).GetProperty("processId").GetString();
processId = post("/processes", {
"orgAddress": org,
"census": {"authFields": ["memberNumber"]},
"title": {"default": "Board election 2027"},
"startDate": "2027-07-01T09:00:00Z", "endDate": "2027-07-03T18:00:00Z",
"questions": [{
"title": {"default": "Who should chair the board?"},
"choices": [{"title": {"default": "Ada Lovelace"}, "value": 0},
{"title": {"default": "Alan Turing"}, "value": 1}],
"type": "singlechoice",
}]}).json()["processId"]
{ "processId": "6a1f..." } // 200 - carry forward
{ "processId": "6a1f...", "missingData": ["665f...", "6660..."] } // some members left out
The census is built when the draft is created, and two kinds of member are handled there rather than at publish:
- Members missing the data to log in - an empty or whitespace-only
authFieldsvalue, or no channel at all for thetwoFaFields- could never authenticate, so they are left out of the census and their member ids come back inmissingData. The draft is still created; fix their data and update the draft (or, once published, grow the census), or ignore them.missingDatais absent when nobody was left out. - Members the census cannot tell apart - the same login data as another member, including values
that differ only by case, or as a participant already in the census - are refused: the request
fails with
400(error code40037) and their ids indata.duplicates, and nothing is created.
Not in the SDK yet
client.elections.create() resolves to the processId alone and client.elections.update() to
nothing, so both drop missingData. Check the census with
validateCensus() first if you need to know who would
be left out.
Not on your integrator organization
Your integrator's own top-level organization cannot own processes - creating one there fails with
403 (error code 40174). Create processes inside a
managed organization instead.
Collecting a free-text answer
To give a question an "Other" free-text option, mark one of its choices "openValue": true. See
Open-value choices for which types allow it and
the one-per-question rule, and Casting votes for
how a voter fills it in.
Editing a draft
While a process is unpublished you can replace its fields with the same body. Once published it is
immutable - the update returns 409. The census is rebuilt as on create, so the response carries the
same { processId, missingData } shape and refuses indistinguishable members the same way.
To stop two editors overwriting each other, send the updatedAt you read from
GET /processes/{processId} in the body: the update is then rejected with 409 (error code 40171) if
anything wrote the process in between. Without it the last write wins. The SDK types do not carry
updatedAt yet.
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS" -d '{ ...same shape as create... }'
await client.elections.update(processId, draft) // draft: the same shape you passed to create
Delete a draft you no longer need (allowed only while unpublished):
curl "${auth[@]}" -X DELETE "$B/processes/$PROCESS"
await client.elections.delete(processId)
Reading a process
GET /processes/{processId} returns the process with every question fully hydrated (upstreamId,
synced status, and live per-question results). GET /processes lists them paginated, filterable by
orgAddress, question status, and published (true for published processes only, false for
drafts only, which needs a manager/admin).
These reads are public for published processes - anyone can read them, no API key. Three things are
gated to a manager/admin of the org (or a voting:write API key acting as one):
-
drafts (
published: false) - the single read returns404for everyone else (hiding existence), and the list returns published processes only; -
eligibleMemberIdson each question (who may vote) - stripped for non-managers. A voter checks their own per-question eligibility withPOST /processes/{processId}/check; -
results.memoson an open-value question - the free-text voter memos, returned only to a manager and absent for everyone else. See Voter memos. -
GET
/processes/{processId} -
GET
/processes -
GET
/processes/{processId}/questions/{questionId}
# public read of a published process (no auth)
curl -s "$B/processes/$PROCESS"
# a manager (or voting:write key) also sees drafts and eligibleMemberIds
curl -s "${auth[@]}" "$B/processes?orgAddress=$ORG&status=READY&page=1"
// public read of a published process (no authToken)
const anon = new VocdoniApiClient({ apiUrl: 'https://saas-api-stg.vocdoni.net' })
const { questions } = await anon.elections.get(processId)
const question = await anon.elections.getQuestion(processId, questions[0].id)
// a manager (or voting:write key) also sees drafts and eligibleMemberIds
const { processes } = await client.elections.list({ orgAddress: org, status: 'READY', page: 1 })
{
"id": "6a1f...", "orgAddress": "0x...", "published": true,
"census": { "authFields": ["memberNumber"], "size": 500, "totalWeight": 500 },
"title": { "default": "Board election 2027" },
"startDate": "2027-07-01T09:00:00Z", "endDate": "2027-07-03T18:00:00Z",
"questions": [{
"id": "b2c3...", "upstreamId": "a1b2...64hex...", "parentProcessId": "6a1f...",
"status": "READY", "type": "singlechoice",
"title": { "default": "Who should chair the board?" },
"choices": [ /* ... */ ],
"results": { "voteCount": 12, "maxVoters": 500, "finalResults": false, "results": [ ["7", "5"] ] }
}]
}
The per-question read (/questions/{questionId}) is public - voter UIs use it to render a
question and its status without authenticating. A question's id is the value used as the
{questionId} path parameter, and as questionId in the results and status payloads.
A question created with secretUntilTheEnd also carries encryptionKeys (an array of
{ index, key }) - the on-chain keys voters seal their ballots with. The field is absent until the
keykeepers publish the keys, so treat its absence as "not yet published" and poll. See
Casting votes for the encrypted-vote flow.
Every published question carries its live tally inline as a results object (voteCount,
maxVoters, finalResults, and the results matrix); finalResults marks live vs final. The object
is absent only for a draft (no election yet). A published question with no votes yet has a
zero-filled matrix; while a secretUntilTheEnd question is still encrypted the inner results is
omitted (only voteCount moves) - poll until it appears. On an
open-value question a manager/admin also gets a
memos array inside this object; it is absent for everyone else. The GET /processes list does
not resolve results. See Results.
A question that was ended early - set to ENDED before its endDate - carries endedAt, the
moment its election actually stopped accepting votes. The process carries an endedAt too, the latest
of its questions', but only once every published question has one. Both are absent while voting is open
and when a question ran to its scheduled end, so display the close time as endedAt ?? endDate.
The values are stored, so the list carries them too - except for an older vote ended early, which gets
them only once its detail (GET /processes/{processId}) or one of its questions
(GET /processes/{processId}/questions/{questionId}) has been read.
The census object also carries response-only size (eligible-voter count, on every read) and
totalWeight (the sum of members' weights - equals size for a non-weighted census), the
denominator for turning weighted results into percentages. totalWeight is resolved only on the
detail read GET /processes/{processId} (not the list) and is absent when it cannot be computed.
Elections from the previous API
Elections created before the /processes API also show up in these reads, as read-only
projections marked legacy: true. GET /processes?orgAddress=... lists them after the stored
processes (pagination.totalItems counts them), and GET /processes/{processId} accepts either a
processId or the election's 64-hex on-chain id. Their questions may share one upstreamId (one
election held the whole ballot). When the election's ballot parameters map onto its single questions,
each question carries the ballotProtocol read from the chain, and a type when that protocol matches
a named type, so its results read the same way as any other question's; with no named type, a
typeSetup may still be filled from the election metadata. When the parameters do not map,
ballotProtocol is absent, type is empty and typeSetup is zeroed. The results object is always there, but its inner
results matrix is left out when the tally cannot be split per question. They cannot be edited or
published through /processes. The SDK types do not carry the legacy flag yet.
Checking readiness
Before publishing, dry-run the publish preconditions. It changes nothing and lists what is still missing (dates, choices, a resolvable census, ballot params within your plan).
curl "${auth[@]}" "$B/processes/$PROCESS/validation"
const { valid, errors } = await client.elections.validate(processId)
{ "valid": true, "errors": [] }
Publishing on-chain
Publishing is asynchronous and atomic: the census and one election per question are published
in a single batch. It returns a jobId; poll the job until it completes.
Either all questions publish or none do.
PJOB=$(curl -s "${auth[@]}" -X POST "$B/processes/$PROCESS/publish" | jq -r .jobId)
# no jobId: the process was already published, or the publish was rejected
if [ -z "$PJOB" ] || [ "$PJOB" = "null" ]; then echo "no jobId - check the publish response" >&2; else
for i in $(seq 150); do
S=$(curl -s "$B/jobs/$PJOB" | jq -r .status)
[ "$S" = "completed" ] && break
if [ "$S" = "failed" ] || [ "$i" = 150 ]; then echo "publish job $PJOB: $S" >&2; break; fi
sleep 2
done
fi
// Publishes and polls the job; throws JobFailedError if the publish fails.
// The wait gives up after 60s by default while the publish carries on; allow longer.
await client.elections.publishAndWait(processId, { timeoutMs: 5 * 60_000 })
On success each question gains its upstreamId and a status of READY (or PAUSED, see below),
and the process flips to published: true. Re-read the process to get the upstreamIds that voters
sign against.
Publishing paused
Set "initialStatus": "PAUSED" on the draft to publish every question paused: the elections exist
on chain, but voting does not open - not even at startDate - until you set each question to READY
with a status change. Use it when an organizer must open the vote by hand. Only
READY (the default) and PAUSED are accepted; anything else is a 400. Reads echo the value as
initialStatus (absent for the default); it records how the process was published, not the questions'
current status.
# the draft is created (or updated) with "initialStatus": "PAUSED" alongside the usual fields
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS" -d '{ ...same shape as create..., "initialStatus": "PAUSED" }'
# publish as usual: every question lands PAUSED
curl "${auth[@]}" -X POST "$B/processes/$PROCESS/publish"
# later, open the vote on every published question
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS/questions/status" -d '{"status":"READY"}'
// draft: the same shape you passed to create. initialStatus is not in the SDK types yet, so cast;
// the client sends it through as is
await client.elections.update(processId, { ...draft, initialStatus: 'PAUSED' } as typeof draft)
await client.elections.publishAndWait(processId, { timeoutMs: 5 * 60_000 })
// later, open the vote on every published question
const { jobId } = await client.elections.bulkSetQuestionStatus(processId, { status: 'READY' })
await client.jobs.waitFor(jobId)
Managing a published census
Publishing does not freeze the census. You can still grow the process census with new members, remove members from it, and replace a question's eligibility subset - even while voting is ongoing. Changes to the memberbase itself also cascade into live censuses - see Kept in sync with the memberbase.
Growing the census
After publishing you can add more members to the census - PUT /processes/{processId}/census adds
existing organization members and raises each affected election's maxCensusSize so they can vote.
Members are added synchronously; the on-chain resize runs as an async job
(jobId). Only questions that are still READY or PAUSED are resized, and questions with an
eligibility subset keep their fixed size and are
unaffected. When no election needs to grow - every question already ended on chain, say, or already
has the room - the members are still added and the call answers 200 with no jobId; otherwise
it answers 202 with the jobId to poll.
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS/census" -d '{"memberIds":["<id1>","<id2>"]}'
const { added, jobId } = await client.elections.addCensusMembers(processId, ['<id1>', '<id2>'])
if (jobId) await client.jobs.waitFor(jobId) // the on-chain resize
{ "added": 2, "jobId": "e5f6a7..." } // 202 - resize enqueued, poll /jobs/{jobId}
{ "added": 2 } // 200 - no election to grow, nothing to poll
Removing members from the census
The reverse of growing: remove members from the process census and from every question eligibility list built on it, so the credential service stops signing for them. An id naming a member who is no longer in the census is skipped as a no-op rather than refused. At most 1000 ids per request - page through a larger removal.
curl "${auth[@]}" -X DELETE "$B/processes/$PROCESS/census" -d '{"memberIds":["<id1>","<id2>"]}'
{ "removed": 2 } // 200 - removed
{ "removed": 2, "jobId": "a7b8c9..." } // 202 - resize enqueued, poll /jobs/{jobId}
Not in the SDK yet
@vocdoni/api-client does not wrap this endpoint yet. Call it with any HTTP client, sending your
key as the bearer token.
Removing a member the CSP has already signed for, while a question of the process is still
READY or PAUSED, is refused with 409 and the offending ids in data.signedMemberIds - once
voting closes on those questions the removal succeeds. This is the same protection that guards
memberbase removals. Pruning a
question's eligibility list to empty opens it to the whole census,
so a maxCensusSize increase may be enqueued as an async job - the 202
case above. Both endpoints omit errors unless something went wrong; on PUT it lists the ids that
could not be added, on DELETE the questions whose resize could not be enqueued.
Changing a question's eligibility
Replace the set of members eligible to vote one question - on a draft or a published process,
even mid-vote. The body is the complete desired list, not a delta, so the request is idempotent:
resend the whole list to change it. Every id must already be a participant of the process census
(grow the census first if not); input order is preserved and duplicates are dropped. Requires a
manager/admin of the org, or a voting:write API key.
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS/questions/$QID/census" \
-d '{"memberIds":["<id1>","<id2>"]}'
{ "eligible": 2 } // 200 - updated, no on-chain resize needed
{ "eligible": 9, "jobId": "f6a7b8..." } // 202 - resize enqueued, poll /jobs/{jobId}
Not in the SDK yet
@vocdoni/api-client does not wrap this endpoint yet. Call it with any HTTP client, sending your
key as the bearer token.
An empty list means "no restriction", not "nobody"
Sending {"memberIds": []} reopens the question to every member of the process census. A
response of eligible: 0 therefore means the question is open to everyone.
Because reopening a restricted question can multiply its electorate beyond what its election was
sized for on chain, a maxCensusSize increase is enqueued as an async job
whenever the question needs more room than it was published with - the 202 case above.
Removing a member the CSP has already signed for, while a question of the process is still
READY or PAUSED, is refused with 409 and the offending ids in data.signedMemberIds - the
same protection that guards memberbase removals.
A 409 is also returned while a publish is in progress or when the list changed concurrently.
Changing status
Move published questions through READY, PAUSED, ENDED, or CANCELED - one at a time or in bulk.
Both are asynchronous jobs. Only published questions (those with an upstreamId) can change status.
Status is case-insensitive on input and returned uppercase. Reads may also show RESULTS once a
question has been tallied - a terminal state you observe but cannot set.
# one question
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS/questions/$QID/status" -d '{"status":"ENDED"}'
# many questions (omit "questions" to target all published questions)
curl "${auth[@]}" -X PUT "$B/processes/$PROCESS/questions/status" -d @- <<JSON
{
"status": "ENDED",
"questions": [ { "id": "$QID" } ]
}
JSON
// one question
const { jobId } = await client.elections.setQuestionStatus(processId, questionId, 'ENDED')
await client.jobs.waitFor(jobId)
// many questions (omit "questions" to target all published questions)
const bulk = await client.elections.bulkSetQuestionStatus(processId, {
status: 'ENDED',
questions: [{ id: questionId }],
})
await client.jobs.waitFor(bulk.jobId)
{ "jobId": "d4e5f6..." } // 202 - poll /jobs/{jobId}
Reading results
Each question tallies independently. See Results for the per-question response shape and how finality works.
Gotchas
- A process is a draft until you publish it; edits are allowed only while
published: false. - Publish and status changes are jobs - read the outcome from
/jobs/{jobId}, not the POST body. - Address the process by its
processIdeverywhere server-side. A question'supstreamIdis only needed client-side, when a voter signs a ballot for that question. - The inline census id is internal - you never send or receive it. See Census.