Public voting
Build a voting interface in your product with the public voting API.
Use these endpoints to build your own voting interface. Call them from your server with an API key that has the vote scope. Do not call them from browser code.
The organizer key never sees a voter key. Pepvote creates and emails a voter key when your server requests one. The email still links to Pepvote, but the voter uses the key in your interface. Your interface sends that key to your server, and your server relays it to Pepvote.
There are no ballot attempts, sessions, or tokens in this API.
Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /public/voting-events/:idOrCode | vote | Get event settings, voter identity rules, and payment settings. |
GET | /public/voting-events/:idOrCode/ballot | vote | Get positions, candidates, sponsors, and groups for a ballot. |
POST | /public/voting-events/:idOrCode/register/voter | vote | Register a voter. |
POST | /public/voting-events/:idOrCode/register/candidate | vote | Register a candidate or start and finish payment. |
POST | /public/voting-events/:idOrCode/voter-key/request | vote | Email a voter key. |
POST | /public/voting-events/:idOrCode/voter/check | vote | Check eligibility before you show a ballot. |
POST | /public/voting-events/:idOrCode/ballot/cast | vote | Cast a ballot or start and finish payment. |
POST | /public/voting-events/:idOrCode/ballot/confirm | vote | Confirm a cast ballot. |
GET | /public/voting-events/:idOrCode/results | vote | Get published or live results. |
GET | /public/voting-events/:idOrCode/quadratic-credits | vote | Get a quadratic credit balance. |
GET | /public/voting-events/:idOrCode/quadratic-credits/options | vote | Get available credit purchases. |
POST | /public/voting-events/:idOrCode/quadratic-credits/checkout | vote | Start or finish a credit purchase. |
GET | /public/voting-events/:idOrCode/payments/:id | vote | Get a payment attempt for the voter. |
Identity model
Elections use a voter ID and a voter key. Request a key with voterId when the event requires voter IDs. Otherwise request it with email.
Polls require visitorId on eligibility, cast, and quadratic credit requests. It must be vi_ followed by 16 to 128 hexadecimal characters. Use one of these choices, in order:
- If you want email-based deduplication, turn on
saveVoterInfoand sendemail. - Otherwise, use
visitorId = "vi_" + sha256(lowercased email). - Or use an HMAC of your own user ID.
A raw email is accepted as a voter ID in some poll flows, but do not use it as visitorId. A visitor ID becomes the visible voter ID and the credit ledger key.
Read voter.pollIdentity from the event context. It tells you which poll identity the event uses. The context also includes payments, which tells you if checkout is needed.
Voter registration
Use this only when the event lets voters self-register.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1 to 200 characters. |
email | string | Yes | Up to 320 characters. Must accept mail; Pepvote checks the domain. |
voterId | string | No | Up to 200 characters. Required when the event requires voter IDs. |
groupId | string | No | Group ID from the ballot. |
curl -X POST "https://api.pepvote.com/public/voting-events/BOARD26/register/voter" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: voter-registration-001" \
-d '{"name":"Grace Hopper","email":"grace@example.com","voterId":"MEM-42"}'{
"data": {
"id": "j57voter01",
"name": "Grace Hopper",
"email": "grace@example.com",
"voterId": "MEM-42",
"status": "pending",
"weight": 1
}
}Candidate registration
Paid registration needs email and returns 402. Follow payments to complete checkout, then send the same endpoint { "paymentAttemptId": "..." } and nothing else.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1 to 200 characters. |
positionIds | string array | Yes | One or more position IDs. |
email | string | Paid events | Up to 320 characters. |
groupId | string | No | Group ID. |
avatarUrl | string | No | External image URL, up to 2,048 characters. |
returnUrl | string | Stripe events | Absolute URL the provider redirects to after payment, with paymentAttemptId appended. |
cancelUrl | string | No | Stripe only. Back link on the hosted page. |
paymentAttemptId | string | Second call | Send alone to finish a paid registration. |
curl -X POST "https://api.pepvote.com/public/voting-events/BOARD26/register/candidate" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: candidate-registration-001" \
-d '{"name":"Ada Lovelace","email":"ada@example.com","positionIds":["j57position01"],"avatarUrl":"https://images.example/ada.png"}'{
"data": {
"id": "j57candidate01",
"name": "Ada Lovelace",
"positionIds": [
"j57position01"
],
"avatarUrl": "https://images.example/ada.png",
"status": "pending"
}
}Ballot preview
Get the event context before you render the ballot. Then get the ballot. You can send an optional seed query parameter to keep a shuffled order stable for one voter.
curl "https://api.pepvote.com/public/voting-events/BOARD26/ballot?seed=member-42" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0"{
"data": {
"votingEventId": "j57event01",
"positions": [
{
"id": "j57position01",
"title": "President",
"votingMethod": "plurality",
"group": null
}
],
"candidates": [
{
"id": "j57candidate01",
"name": "Ada Lovelace",
"positionIds": [
"j57position01"
],
"group": null,
"referendumRole": null,
"avatarUrl": null
}
],
"sponsors": [
]
}
}Ballot entries
Every cast sends votes, an array of 1 to 200 entries. One entry selects one candidate for one position. The ballot response tells you each position's votingMethod, minSelections, maxSelections, and for score positions scoreMin and scoreMax, for cumulative positions cumulativePointBudget, and for quadratic positions quadraticBaseCreditBudget.
| Field | Type | Required | Description |
|---|---|---|---|
positionId | string | Yes | Position from the ballot. |
candidateId | string | Yes | Candidate from the ballot. The same candidate cannot appear twice for one position. |
value | number | Depends on method | See the table below. |
rank | integer | Ranked choice only | 1 for first choice, 2 for second, and so on. Unique per position. |
| Voting method | value | rank |
|---|---|---|
plurality, approval, referendum | Omit, or send 1. | Omit. |
score | Required. Whole number from scoreMin to scoreMax. | Omit. |
cumulative | Required. Positive whole number of points. The position total must stay within cumulativePointBudget. | Omit. |
quadratic | Required. Positive whole number of votes for that candidate. Credits spent are the square, so 3 votes cost 9 credits. | Omit. |
ranked_choice | Omit. | Required. |
A wrong shape returns 422 validation_error naming the entry, for example votes[2].rank.
Election cast
First request a voter key. Send voterId for an event that requires it. Otherwise send email. Pepvote emails the key to the voter. The voter types it into your interface. You then relay it to voter/check and ballot/cast.
voter/check is optional. It always returns 200 with eligible: false and a reason for an eligibility failure. Use it before rendering a ballot if that helps your interface. Cast checks everything again.
curl -X POST "https://api.pepvote.com/public/voting-events/BOARD26/ballot/cast" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ballot-cast-001" \
-d '{"voterId":"MEM-42","voterKey":"voter_key_from_email","countryCode":"CA","votes":[{"positionId":"j57position01","candidateId":"j57candidate01"}]}'{
"data": {
"confirmationCode": "PV-7K2M",
"votesCount": 1
}
}| Field | Type | Required | Description |
|---|---|---|---|
voterKey | string | Yes | The key Pepvote emailed to the voter. |
voterId | string | Event rule | Up to 200 characters. Required when the event requires voter IDs. |
email | string | Event rule | Up to 320 characters. Send when the key was requested by email. |
name | string | No | Up to 200 characters. |
countryCode | string | No | Two-letter country code. |
votes | array | Yes | See ballot entries. |
countryCode is optional. Pepvote stores it as your reported country. API votes do not use VPN detection. Votes have channel api.
voter-key/request takes voterId or email (one is required) and returns { "sent": true, "maskedEmail": "g***@example.com" }. voter/check takes voterKey plus the same voterId, email, or visitorId you will send to cast.
Poll cast
| Field | Type | Required | Description |
|---|---|---|---|
visitorId | string | Yes | vi_ plus 16 to 128 hex characters. See identity model. |
voterId | string | Event rule | Required when the poll requires voter IDs. |
email | string | Event rule | Required when saveVoterInfo is on, and for Paystack checkout. |
name | string | No | Up to 200 characters. |
countryCode | string | No | Two-letter country code. |
votes | array | Yes | See ballot entries. |
returnUrl | string | Stripe events | Pay-to-vote polls only. See payments. |
cancelUrl | string | No | Stripe only. |
paymentAttemptId | string | Second call | Send alone to finish a paid cast. |
On a pay-to-vote poll the first cast returns 402; see payments.
curl -X POST "https://api.pepvote.com/public/voting-events/POLL26/ballot/cast" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: poll-cast-001" \
-d '{"visitorId":"vi_ab12ab12ab12ab12ab12ab12ab12ab12","votes":[{"positionId":"j57position01","candidateId":"j57candidate01"}]}'{
"data": {
"confirmationCode": "PV-9D4R",
"votesCount": 1
}
}Confirm a ballot
Cast does not count votes. It returns a confirmationCode. Confirm that code before you show a success state. The body is { "confirmationCode": "..." }, 1 to 64 characters.
curl -X POST "https://api.pepvote.com/public/voting-events/BOARD26/ballot/confirm" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-d '{"confirmationCode":"PV-7K2M"}'{
"data": {
"confirmationCode": "PV-7K2M",
"confirmedCount": 1,
"totalVotes": 1,
"submittedAt": 1780000000000,
"votes": [
{
"positionName": "President",
"candidateName": "Ada Lovelace"
}
]
}
}Public results
Results are available only when the event allows live or published results.
curl "https://api.pepvote.com/public/voting-events/BOARD26/results" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0"{
"data": {
"votingEventId": "j57event01",
"title": "Board election",
"positions": [
{
"id": "j57position01",
"title": "President",
"candidates": [
{
"id": "j57candidate01",
"name": "Ada Lovelace",
"votesWon": 24
}
],
"totalVotes": 24
}
],
"stats": {
"totalVotes": 24
}
}
}Quadratic credits
Credits belong to a position and are keyed by visitorId. GET .../quadratic-credits and GET .../quadratic-credits/options both take positionId and visitorId as query parameters. Read them before checkout.
| Field | Type | Required | Description |
|---|---|---|---|
positionId | string | Yes | A quadratic position that allows top-ups. |
visitorId | string | Yes | Same value the voter will cast with. |
credits | integer | Ratio pricing | Number of credits, at least 1. Send this or tierId, not both. |
tierId | string | Tiered pricing | Tier from the options response. |
email | string | Paystack events | Up to 320 characters. |
voterId, name | string | No | Stored on the purchase. |
returnUrl | string | Stripe events | See payments. |
cancelUrl | string | No | Stripe only. |
paymentAttemptId | string | Second call | Send alone to finish the purchase. |
curl -X POST "https://api.pepvote.com/public/voting-events/POLL26/quadratic-credits/checkout" \
-H "Authorization: Bearer $PEPVOTE_VOTE_KEY" \
-H "User-Agent: my-app/1.0" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: credit-checkout-001" \
-d '{"positionId":"j57position01","visitorId":"vi_ab12ab12ab12ab12ab12ab12ab12ab12","credits":20,"returnUrl":"https://your-app.example/credits"}'{
"data": {
"paymentRequired": true,
"paymentAttemptId": "j57payment01",
"amount": 5,
"currency": "usd",
"provider": "paystack",
"paystack": {
"checkoutUrl": "https://checkout.paystack.com/example",
"accessCode": "access_code",
"reference": "reference"
},
"credits": 20,
"pricingModel": "ratio"
}
}Read payments for the checkout completion flow.
Last updated September 6, 2026