Members and groups
Members are the people in your organization. Import them once, organize them into groups, and reuse them in the census of many processes.
Members are an organization's people - your customer's voters. Groups are named subsets of members, and a group can populate a process census by reference (see Census).
The member object
A member carries identity and contact fields plus an optional census weight and arbitrary custom fields. Provide whatever your authentication strategy needs; you do not have to fill every field.
| Field | Type | Description |
|---|---|---|
memberNumber |
string | Your identifier for the member, unique within the organization. |
name |
string | Given name. |
surname |
string | Family name. |
email |
string | Email, used for email-based authentication and reminders. |
phone |
string | Phone number, used for SMS authentication. |
nationalId |
string | National identity document, when used to authenticate. |
birthDate |
string | Date of birth in YYYY-MM-DD format. |
weight |
string | Vote weight for weighted censuses. A string, e.g. "1". Defaults to 1. |
password |
string | Write-only. The client hash of the member's password, never the plaintext. |
hasPassword |
boolean | Read-only. true when the member has a password; ignored on writes. |
other |
object | Custom key-value fields specific to your organization. |
Member passwords
A member password is a secret the organization gives each voter, for a census that requires it as a
credential. The plaintext never reaches the API: you send
the client hash in password, and the voter app sends the same hash, computed from what the voter
types, when they log in.
password = lowercase_hex(SHA-256(UTF-8(
"vocdoni-member-password:" + canonicalAddress(orgAddress) + ":" + NFC(trim(password)))))
canonicalAddress(a) = "0x" + lowercase(a without any leading "0x")
trim is JavaScript's String.prototype.trim(): it strips the ECMAScript whitespace and line
terminators, U+FEFF included, from both ends. Other languages' trims strip a different set, so match it
exactly or a password with unusual leading or trailing characters hashes differently on each side.
The organization address is part of the hash, so the same password hashes differently in each
organization. Normalizing to NFC makes an accented password hash alike in whichever Unicode form it
was typed. Test vector: organization 0x00000000000000000000000000000000000000AA (or
00000000000000000000000000000000000000aa) and password s3cret give
2f7e83a0b19a82fa9a5dc6ecc6ff44ec82679ea87012a7187bd3313366bf277e.
In TypeScript, hashMemberPassword(orgAddress, password) from @vocdoni/api-client (2.7.0 on) does
all of this. It uses Web Crypto, so it runs in browsers on a secure (HTTPS) page and in Node 22 or
later. In other languages, implement the formula as the Python version below does.
import { hashMemberPassword } from '@vocdoni/api-client' // from 2.7.0
// Throws on an empty password (its hash is a valid, guessable credential: leave `password` out
// instead) and on an address that is not 0x + 40 hex (the hash would never match at login).
const password = await hashMemberPassword(org, 's3cret')
await client.organizations.upsertMember(org, { id: memberId, password })
import hashlib, re, unicodedata
# what JavaScript's String.prototype.trim() strips; Python's str.strip() strips a different set
JS_WHITESPACE = (
"\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a"
"\u2028\u2029\u202f\u205f\u3000\ufeff"
)
def member_password_hash(org_address: str, password: str) -> str:
org = "0x" + org_address.lower().removeprefix("0x")
# a wrong address still yields a valid-looking hash, and every login would then fail
if not re.fullmatch(r"0x[0-9a-f]{40}", org):
raise ValueError(f"not an organization address: {org_address!r}")
pw = unicodedata.normalize("NFC", password.strip(JS_WHITESPACE))
# the hash of "" is a valid credential: send no password (or "" to clear it) instead
if not pw:
raise ValueError("empty password")
return hashlib.sha256(f"vocdoni-member-password:{org}:{pw}".encode()).hexdigest()
passwordtakes a hash of 64 lowercase hex characters or""(no password). Anything else is refused: a bulk add fails as a whole with400(error code40037), an upsert with400(error code40004).- Never hash an empty password: the hash of
""is a valid value that anyone can compute. Leavepasswordout for members who have none, so a password census reports them inmissingData. - Reads never return the password, only
hasPassword: truewhen one is set. - On an update,
""clears the password and leavingpasswordout keeps it. Clearing the password of a member already in a live password census keeps them in it, but they can no longer log in until a new one is set. - The hash logs a voter in just like the password, and as a single SHA-256 it is quick to brute-force for a weak password. Store, log and transmit it as carefully as the password itself.
Set passwords before creating the process
A member who has no password when the census is built is left out of it (reported in missingData),
and setting the password later does not add them. See
Creating a process.
Adding members
Member imports are bulk. Add ?async=true ({ async: true } in the SDK) to run them as a job: the
call returns a jobId, and you poll a members-job until it reports progress: 100. Without it the call
imports synchronously and answers { "added": N } with no jobId - unless the members carry more than
150 phones and passwords between them, which are slow to hash: then the import runs as a job anyway and
answers with a jobId, so always check for one.
JOB=$(curl -s "${auth[@]}" -X POST "$B/organizations/$ORG/members?async=true" -d '{
"members": [
{ "name": "Alice", "surname": "Doe", "email": "alice@example.org",
"memberNumber": "A-101", "weight": "1" }
]
}' | jq -r .jobId)
# poll the members-job until done
# a members-job never fails: a row that cannot be stored leaves it pending, so poll with a deadline
if [ -z "$JOB" ] || [ "$JOB" = "null" ]; then echo "no jobId - check the import response" >&2; else
for i in $(seq 120); do
[ "$(curl -s "${auth[@]}" "$B/jobs/$JOB" | jq -r .status)" = "completed" ] && break
[ "$i" = 120 ] && echo "members-job $JOB did not complete - do not build the census yet" >&2
sleep 1
done
fi
const { jobId } = await client.organizations.addMembers(
org,
// weight is a decimal string ("2") and defaults to 1 (typed as a string since @vocdoni/api-client 2.4.0)
[{ name: 'Alice', surname: 'Doe', email: 'alice@example.org', memberNumber: 'A-101', weight: '1' }],
{ async: true },
)
// Polls the members-job until progress 100. A members-job never reports `failed`: if a row cannot be
// stored it stays pending below 100, so this times out (60s by default - raise timeoutMs for large imports).
if (jobId) await client.jobs.waitFor(jobId, { timeoutMs: 10 * 60_000 })
var job = (await Post($"/organizations/{org}/members?async=true",
new { members = new[] { new { name = "Alice", memberNumber = "A-101", weight = "1" } } }))
.GetProperty("jobId").GetString();
// completes at progress 100; a failed row keeps it pending, hence the deadline
for (var i = 1; (await Get($"/jobs/{job}")).GetProperty("status").GetString() != "completed"; i++)
{
if (i == 120) throw new Exception($"members-job {job} did not complete");
await Task.Delay(1000);
}
job = post(f"/organizations/{org}/members?async=true",
{"members": [{"name": "Alice", "memberNumber": "A-101", "weight": "1"}]}).json()["jobId"]
for _ in range(120): # completes at progress 100; a failed row keeps it pending, hence the deadline
if get(f"/jobs/{job}").json()["status"] == "completed":
break
time.sleep(1)
else:
raise RuntimeError(f"members-job {job} did not complete")
// GET /jobs/{jobId}
{ "type": "org_members", "status": "completed",
"result": { "added": 1, "total": 1, "progress": 100 } } // errors omitempty: absent when empty
Wait for the import job
Don't build the census until the members-job reaches progress: 100 - the participants won't be
there yet. A members-job never reports failed: when a row cannot be stored the job stays pending
with progress below 100 (or absent), so give your poll loop a deadline instead of waiting forever.
See Jobs for the full job model.
Listing members
The list is paginated (default limit is small) - see
Pagination. Walk every page so large memberbases aren't
silently truncated.
curl "${auth[@]}" "$B/organizations/$ORG/members?page=1&limit=100"
// the SDK sends only the page; the backend default page size applies
const { members, pagination } = await client.organizations.listMembers(org, 1)
{ "members": [ { "id": "...", "memberNumber": "A-101", "name": "Alice" } ],
"pagination": { "currentPage": 1, "lastPage": 1, "totalItems": 1 } }
The list is ordered by name ascending unless you pass sortBy (name, surname, email or
memberNumber) and sortOrder (asc or desc); an unknown value is a 400 (error code 40010).
Ordering ignores case and accents and compares digit runs as numbers (63 before 273). Ties are
broken by the member id (after the other name field, when sorting by name or surname), so pages
stay stable while you walk them. search narrows the list to members whose fields match a term.
curl "${auth[@]}" "$B/organizations/$ORG/members?sortBy=memberNumber&sortOrder=desc&limit=100"
Not in the SDK yet
client.organizations.listMembers() sends only the page: the SDK does not wrap this endpoint's
limit, search, sortBy or sortOrder parameters yet.
members, page = [], 1
while True:
r = get(f"/organizations/{org}/members?page={page}&limit=100").json()
members += r["members"]
p = r.get("pagination")
if not r["members"] or not p or p["currentPage"] >= p["lastPage"]:
break
page += 1
const members = []
for (let page = 1; ; page++) {
const r = await client.organizations.listMembers(org, page)
members.push(...r.members)
const p = r.pagination
if (!r.members.length || !p || p.currentPage >= p.lastPage) break
}
Updating and deleting members
Update a single member, or delete members by id. Note the delete path is plural with a body of
ids; the singular /member returns 404 on the deployed backend.
The PUT is an upsert: send the id of an existing member to update it. An empty or unknown id
creates a new member instead, holding only the fields you sent and weight 1, and that member joins
the live censuses built from the all-members group.
An update merges what you send onto the stored member:
- A field left out, or sent as
null, keeps its stored value. - A field sent as
""clears it. Forweight,""resets it to the default1, so leaveweightout to keep the member's vote in a weighted census unchanged. otheris replaced as a whole: send every custom field you want to keep,{}to clear them all, or leaveotherout to keep them.- Phone numbers are never returned in plaintext, so leave
phoneout unless you are changing it. Neither is the password: leavepasswordout to keep it, or send""to clear it. - The whole member is normalized again on every update (names and ids trimmed, email lowercased), so fields you did not send can change format.
Clearing a field that a live census authenticates with (for example email or memberNumber) still
succeeds, but leaves that member unable to authenticate in the census.
curl "${auth[@]}" -X PUT "$B/organizations/$ORG/members" \
-d '{"id":"<memberId>","memberNumber":"A-101","email":"alice@example.org"}'
curl "${auth[@]}" -X DELETE "$B/organizations/$ORG/members" -d '{"ids":["<memberId>"]}'
// Fields left out, weight included, keep their stored values. To change the weight, send it as a
// string: weight: '2'.
await client.organizations.upsertMember(org, { id: memberId, memberNumber: 'A-101', email: 'alice@example.org' })
await client.organizations.deleteMembers(org, { ids: [memberId] })
The PUT answers { "id": "..." }. When it creates a member it can also return censusJobIds, the
census-update jobs that make room for them in live
elections (poll each one), and errors, the live censuses they could not join.
Hashing a phone or a password is rationed server-wide, so a busy server answers the PUT with 503
(error code 50302) and a Retry-After header, and writes nothing: retry after the delay.
Member and group changes cascade to the censuses of ongoing processes - the memberbase is the source of truth (see Census). Two consequences for these endpoints:
- Deleting a voter who already got their ballot signed is refused. If the credential service has
already signed for a member on a question that is still
READYorPAUSED, the delete returns a409(error code40173) with the offending ids indata.signedMemberIds- one entry per member, in the order you sent them. Retry once voting closes on those questions; deletion is never permanently blocked. The same guard applies to removing such a member from a group a live census was built from. - Additions propagate. A new member, or a member added to a group, joins the live censuses built from that group and can vote; creations are gated by your plan's census quota.
Malformed or unknown ids in the delete body are ignored rather than failing the request.
Groups
A group is a named subset of members. The common case is an all-members group, which a process
census can reference by groupId to include everyone. You can also build a
group from explicit member ids. To check that a group's members carry the fields a census will
require, validate the census with its groupId - the
per-group validation endpoint of earlier API versions is gone.
GROUP=$(curl -s "${auth[@]}" -X POST "$B/organizations/$ORG/groups" \
-d '{"title":"All voters","includeAllMembers":true}' | jq -r .id)
const { id: group } = await client.organizations.createGroup(org, {
title: 'All voters',
includeAllMembers: true,
})
var group = (await Post($"/organizations/{org}/groups",
new { title = "All voters", includeAllMembers = true })).GetProperty("id").GetString();
group = post(f"/organizations/{org}/groups",
{"title": "All voters", "includeAllMembers": True}).json()["id"]
{ "id": "665f..." } // carry forward: group id
Gotchas
- Adding members with
?async=trueis a job - wait forprogress: 100before building a census. - Listing is paginated - walk the pages.
- Delete is
DELETE /organizations/{addr}/members(plural), with{ "ids": [...] }. - For an auth-only census, each
memberNumbermust be unique - it becomes the voting credential (see Census). passwordtakes the client hash, never the plaintext. Set it before creating the process: members without one are left out of a password census.