# CrownVote Developer API v1

CrownVote Developer API v1 lets developers build custom election frontends while CrownVote handles election scoping, voter authentication, ballot delivery, encrypted vote submission, duplicate prevention, and result integrity.

This file is intended for both humans and AI agents and can be exposed as:

```txt
/docs/api.md
/docs/llms.txt
```

## Base URL

```txt
https://api.crownvote.com/api/v1
```

## Core concepts

| Term | Meaning |
|---|---|
| Election | The voting exercise configured on CrownVote. |
| Voter | The person authorized to vote in the election. |
| Ballot category | A position/category the voter must vote under. |
| Candidate | A contestant/nominee/option listed under a ballot category. |
| Selection | One submitted encrypted vote value for a ballot category. |
| Public key | The election-scoping key sent as `X-PUBLIC-KEY`. |
| Voter token | Bearer token returned after voter login. |
| Admin token | Token returned after admin login for supervised in-person voting. |

---

# Authentication model

Every request to CrownVote Developer API must include the election public key:

```http
X-PUBLIC-KEY: cv_pub_xxxxx
```

Protected voter requests also require the voter bearer token:

```http
Authorization: Bearer oat_xxxxx
```

Supervised in-person requests may also require the admin session token:

```http
X-ADMIN-TOKEN: cv_admin_xxxxx
```

## Headers

| Header | Required | Used for |
|---|---:|---|
| `X-PUBLIC-KEY` | Yes | Scopes every request to the correct election. |
| `Content-Type: application/json` | Required for JSON POST requests | Sends request body as JSON. |
| `Authorization: Bearer <token>` | Required after voter login | Authenticates protected voter actions. |
| `X-ADMIN-TOKEN` | Required only when `inPersonRequireAdminLogin` is `true` | Authenticates the active admin session for supervised in-person voting. |

---

# Standard response format

## Success response

```json
{
  "status": true,
  "data": {}
}
```

## Error response

```json
{
  "status": false,
  "code": "ERROR_CODE",
  "message": "Human readable error message.",
  "errors": []
}
```

Validation errors may include field-level details:

```json
{
  "status": false,
  "code": "REQUEST_VALIDATION_FAILED",
  "message": "Request validation failed.",
  "errors": [
    {
      "field": "selections",
      "message": "The selections field must be defined",
      "rule": "required"
    }
  ]
}
```

---

# UI construction rules from `GET /election`

Every custom frontend should begin by calling `GET /election`.

The response determines which UI to construct.

## Main decision tree

```txt
GET /election
|
|-- votingMode = "remote"
|   |
|   |-- show remote voter login form
|   |-- fields: voterId, password
|   |-- do not show admin login form
|   |-- do not show voter token form
|
|-- votingMode = "in_person"
    |
    |-- always show voter token form
    |
    |-- if inPersonRequireAdminLogin = true
    |      show admin login form first
    |      store admin accessToken in UI/session
    |      send X-ADMIN-TOKEN on supervised requests
    |
    |-- if inPersonRequireAdminLogin = false
    |      do not show admin login form
    |      do not send X-ADMIN-TOKEN
    |
    |-- if inPersonRequireVoterPassword = true
    |      show voter password field with voter token
    |
    |-- if inPersonRequireVoterPassword = false
           show voter token field only
```

## Remote voting UI

For remote voting, show:

```txt
Voter ID input
Password input
Login button
```

Remote voter login payload:

```json
{
  "voterId": "236165",
  "password": "voter-password"
}
```

## In-person voting UI

For in-person voting, always show:

```txt
Voter token input
```

If `inPersonRequireVoterPassword` is `true`, also show:

```txt
Voter password input
```

If `inPersonRequireAdminLogin` is `true`, show admin login before allowing voter login:

```txt
Admin PIN input
Admin password input
Admin login button
```

After admin login succeeds, store:

```txt
data.admin.accessToken
```

Use it as:

```http
X-ADMIN-TOKEN: cv_admin_xxxxx
```

For single-page starter kits, use `sessionStorage` for `adminToken` and `voterToken`. Clear the voter token after every completed vote. Clear the admin token when the polling officer logs out.

---

# Election

## Get election

Resolve the public election attached to the public key.

This endpoint returns the election configuration needed before rendering login forms.

```http
GET /api/v1/election HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
```

## Response: remote election

For remote elections, in-person requirement fields are returned as `null`.

```json
{
  "status": true,
  "data": {
    "election": {
      "title": "SRC General Elections 2026",
      "institution": "CrownVote Demo Institution",
      "description": "Official voting event for the SRC General Elections.",
      "instructions": "Login with your voter ID and password to cast your vote.",
      "status": "live",
      "startsAt": "2026-06-01T08:00:00.000Z",
      "endsAt": "2026-06-01T17:00:00.000Z",
      "votingMode": "remote",
      "accessMode": "api",
      "inPersonRequireAdminLogin": null,
      "inPersonRequireVoterPassword": null
    }
  }
}
```

## Response: in-person election

For in-person elections, the response tells the frontend whether admin login and voter password are required.

```json
{
  "status": true,
  "data": {
    "election": {
      "title": "SRC General Elections 2026",
      "institution": "CrownVote Demo Institution",
      "description": "Official in-person voting session.",
      "instructions": "Present your voting token to the polling officer.",
      "status": "live",
      "startsAt": "2026-06-01T08:00:00.000Z",
      "endsAt": "2026-06-01T17:00:00.000Z",
      "votingMode": "in_person",
      "accessMode": "api",
      "inPersonRequireAdminLogin": true,
      "inPersonRequireVoterPassword": false
    }
  }
}
```

## Election response fields

| Field | Type | Description |
|---|---|---|
| `title` | `string` | Public title of the election. |
| `institution` | `string \| null` | Institution or organization running the election. |
| `description` | `string \| null` | Public election description. |
| `instructions` | `string \| null` | Instructions shown to voters. |
| `status` | `string` | Current election status. Common values include `draft`, `live`, `paused`, `cancelled`, and `archived`. |
| `startsAt` | `string \| null` | Election start date/time. |
| `endsAt` | `string \| null` | Election end date/time. |
| `votingMode` | `remote \| in_person` | Determines whether the election is remote or in-person. |
| `accessMode` | `portal \| api` | Determines whether the election is accessed through CrownVote portal or the Developer API. |
| `inPersonRequireAdminLogin` | `boolean \| null` | `true` or `false` for in-person elections. `null` for remote elections. |
| `inPersonRequireVoterPassword` | `boolean \| null` | `true` or `false` for in-person elections. `null` for remote elections. |

---

# Remote voting

Remote voting allows voters to authenticate from their own devices using their voter ID and password.

## Remote voting flow

1. Call `GET /election`.
2. Confirm `votingMode` is `remote`.
3. Show voter login form with `voterId` and `password`.
4. Login voter.
5. Render returned ballots.
6. Submit encrypted selections.
7. Optionally check submission.
8. Logout voter or clear local session.

## Login voter

```http
POST /api/v1/voter/login HTTP/1.1
Host: api.crownvote.com
Content-Type: application/json
X-PUBLIC-KEY: cv_pub_xxxxx

{
  "voterId": "236165",
  "password": "voter-password"
}
```

## Login response

```json
{
  "status": true,
  "data": {
    "accessToken": {
      "type": "Bearer",
      "value": "oat_xxxxx",
      "expiresAt": null
    },
    "voter": {
      "voterId": "236165",
      "firstName": "Voter 1",
      "middleName": null,
      "lastName": null,
      "displayName": "Voter 1",
      "group": null,
      "status": "active",
      "hasVoted": false,
      "votedAt": null,
      "lastLoginAt": null
    },
    "ballots": [
      {
        "categoryId": "70f74ae6-fb73-41e9-87e0-ece419585ed5",
        "categoryPosition": 1,
        "title": "PRESIDENT",
        "description": null,
        "instructions": null,
        "candidates": [
          {
            "name": "Kofi Angel",
            "image": "https://cdn-f.crownvote.com/elections/...",
            "bio": null,
            "manifesto": null,
            "vote": "encrypted_vote_value"
          }
        ]
      }
    ]
  }
}
```

## Ballot rendering rules

- Render each item in `ballots` as a ballot category.
- Render each item in `candidates` as a candidate option.
- Treat `candidate.vote` as an opaque encrypted string.
- Do not decode, modify, regenerate, or derive the `vote` value.
- When a candidate is selected, submit the selected candidate's `vote` value with the matching `categoryId`.

## Restore voter session

```http
GET /api/v1/voter HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
Authorization: Bearer oat_xxxxx
```

## Submit votes

```http
POST /api/v1/voter/submit-votes HTTP/1.1
Host: api.crownvote.com
Content-Type: application/json
X-PUBLIC-KEY: cv_pub_xxxxx
Authorization: Bearer oat_xxxxx

{
  "selections": [
    {
      "categoryId": "70f74ae6-fb73-41e9-87e0-ece419585ed5",
      "vote": "encrypted_vote_value_for_candidate"
    }
  ]
}
```

## Submit response

```json
{
  "status": true,
  "message": "Vote submitted successfully.",
  "data": {
    "submission": {
      "status": "pending"
    }
  }
}
```

## Get voter submission

```http
GET /api/v1/voter/submission HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
Authorization: Bearer oat_xxxxx
```

## Logout voter

```http
POST /api/v1/voter/logout HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
Authorization: Bearer oat_xxxxx
```

---

# In-person voting

In-person voting is used for controlled voting centers, institutional voting rooms, polling stations, and supervised voting devices.

## In-person voting flow

1. Call `GET /election`.
2. Confirm `votingMode` is `in_person`.
3. If `inPersonRequireAdminLogin` is `true`, show admin login form.
4. Login admin and store `data.admin.accessToken`.
5. Always show voter token input.
6. If `inPersonRequireVoterPassword` is `true`, show voter password input.
7. Login voter.
8. Render returned ballots.
9. Submit encrypted selections.
10. Clear voter session after each voter.
11. Logout admin when the polling officer is done.

## Admin login

Show this form only if:

```js
election.votingMode === 'in_person' && election.inPersonRequireAdminLogin === true
```

```http
POST /api/v1/admin/login HTTP/1.1
Host: api.crownvote.com
Content-Type: application/json
X-PUBLIC-KEY: cv_pub_xxxxx

{
  "pin": "1234",
  "password": "admin-password"
}
```

## Admin login response

```json
{
  "status": true,
  "data": {
    "admin": {
      "id": "admin_01",
      "name": "Polling Officer",
      "accessToken": "cv_admin_xxxxx"
    }
  }
}
```

Store the admin token:

```js
sessionStorage.setItem('crownvote_admin_token', result.data.admin.accessToken)
```

Send it on supervised requests:

```http
X-ADMIN-TOKEN: cv_admin_xxxxx
```

## Verify admin session

```http
GET /api/v1/admin HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx
```

## Voter login: token only

Use this when `inPersonRequireVoterPassword` is `false`.

```http
POST /api/v1/voter/login HTTP/1.1
Host: api.crownvote.com
Content-Type: application/json
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx

{
  "token": "VTR-92831"
}
```

If `inPersonRequireAdminLogin` is `false`, omit `X-ADMIN-TOKEN`.

## Voter login: token and password

Use this when `inPersonRequireVoterPassword` is `true`.

```http
POST /api/v1/voter/login HTTP/1.1
Host: api.crownvote.com
Content-Type: application/json
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx

{
  "token": "VTR-92831",
  "password": "voter-password"
}
```

If `inPersonRequireAdminLogin` is `false`, omit `X-ADMIN-TOKEN`.

## Voter login response

```json
{
  "status": true,
  "data": {
    "accessToken": {
      "type": "Bearer",
      "value": "oat_xxxxx",
      "expiresAt": null
    },
    "voter": {
      "voterId": "236165",
      "displayName": "Voter 1",
      "status": "active",
      "hasVoted": false
    },
    "ballots": [
      {
        "categoryId": "70f74ae6-fb73-41e9-87e0-ece419585ed5",
        "categoryPosition": 1,
        "title": "PRESIDENT",
        "description": null,
        "instructions": null,
        "candidates": [
          {
            "name": "Kofi Angel",
            "image": "https://cdn-f.crownvote.com/elections/...",
            "bio": null,
            "manifesto": null,
            "vote": "encrypted_vote_value"
          }
        ]
      }
    ]
  }
}
```

## Restore voter session

```http
GET /api/v1/voter HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx
Authorization: Bearer oat_xxxxx
```

If admin login is not required, omit `X-ADMIN-TOKEN`.

## Submit votes

```http
POST /api/v1/voter/submit-votes HTTP/1.1
Host: api.crownvote.com
Content-Type: application/json
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx
Authorization: Bearer oat_xxxxx

{
  "selections": [
    {
      "categoryId": "70f74ae6-fb73-41e9-87e0-ece419585ed5",
      "vote": "encrypted_vote_value_for_candidate"
    }
  ]
}
```

If admin login is not required, omit `X-ADMIN-TOKEN`.

## Logout voter

```http
POST /api/v1/voter/logout HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx
Authorization: Bearer oat_xxxxx
```

If admin login is not required, omit `X-ADMIN-TOKEN`.

## Logout admin

```http
POST /api/v1/admin/logout HTTP/1.1
Host: api.crownvote.com
X-PUBLIC-KEY: cv_pub_xxxxx
X-ADMIN-TOKEN: cv_admin_xxxxx
```

---

# Ballot submission rules

- `X-PUBLIC-KEY` must be sent on every request.
- Voter bearer token must be valid for protected voter requests.
- `X-ADMIN-TOKEN` must be sent only when admin login is required.
- Each submitted `categoryId` must exist in the current ballot payload.
- Submit exactly one selection for every required ballot category.
- Do not submit the same category more than once.
- Submit the encrypted `vote` value exactly as received from CrownVote.
- A voter cannot vote more than once.
- If `STALE_BALLOT_SUBMISSION` is returned, restore the voter session and reload ballots.

---

# Common error codes

| Code | Meaning |
|---|---|
| `ELECTION_NOT_FOUND` | No election could be resolved from the public key. |
| `ELECTION_PAUSED` | The election is not currently accepting voting requests. |
| `ADMIN_LOGIN_NOT_REQUIRED` | Admin login was attempted for an election that does not require admin supervision. |
| `INVALID_ADMIN_CREDENTIALS` | The admin pin or password is incorrect. |
| `UNAUTHORIZED` | The request is not authorized. |
| `VOTER_AUTH_FAILED` | The voter token is missing, invalid, or expired. |
| `INVALID_VOTER_CREDENTIALS` | The voter credentials are incorrect. |
| `INVALID_VOTER` | The voter is invalid or cannot vote in this election. |
| `ALREADY_VOTED` | The voter has already submitted a ballot. |
| `STALE_BALLOT_SUBMISSION` | The submitted selections do not match the current ballot snapshot. |
| `DUPLICATE_CATEGORY_SELECTION` | A ballot category was submitted more than once. |
| `REQUEST_VALIDATION_FAILED` | The request body failed validation. |
| `VOTE_SUBMISSION_FAILED` | The vote submission could not be processed. |
| `SUBMISSION_NOT_FOUND` | No submission exists for the voter. |
| `NOT_FOUND` | API route or resource was not found. |
| `INTERNAL_ERROR` | Internal server error. |
| `UNKNOWN` | Uncommon error status. |

---

# JavaScript quickstart: remote voting

```js
const API_URL = 'https://api.crownvote.com/api/v1'
const PUBLIC_KEY = 'cv_pub_xxxxx'

async function getElection() {
  const response = await fetch(`${API_URL}/election`, {
    headers: {
      'X-PUBLIC-KEY': PUBLIC_KEY,
    },
  })

  return response.json()
}

async function loginVoter({ voterId, password }) {
  const response = await fetch(`${API_URL}/voter/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-PUBLIC-KEY': PUBLIC_KEY,
    },
    body: JSON.stringify({ voterId, password }),
  })

  const result = await response.json()

  if (result.status) {
    sessionStorage.setItem('crownvote_voter_token', result.data.accessToken.value)
  }

  return result
}

async function submitVotes(selections) {
  const token = sessionStorage.getItem('crownvote_voter_token')

  const response = await fetch(`${API_URL}/voter/submit-votes`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-PUBLIC-KEY': PUBLIC_KEY,
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ selections }),
  })

  return response.json()
}
```

---

# JavaScript quickstart: in-person voting

```js
const API_URL = 'https://api.crownvote.com/api/v1'
const PUBLIC_KEY = 'cv_pub_xxxxx'

let election = null
let adminToken = sessionStorage.getItem('crownvote_admin_token')
let voterToken = sessionStorage.getItem('crownvote_voter_token')

async function getElection() {
  const response = await fetch(`${API_URL}/election`, {
    headers: {
      'X-PUBLIC-KEY': PUBLIC_KEY,
    },
  })

  const result = await response.json()

  if (result.status) {
    election = result.data.election
  }

  return result
}

function shouldShowAdminLoginForm() {
  return election?.votingMode === 'in_person'
    && election?.inPersonRequireAdminLogin === true
}

function shouldShowVoterPasswordField() {
  return election?.votingMode === 'in_person'
    && election?.inPersonRequireVoterPassword === true
}

async function loginAdmin({ pin, password }) {
  const response = await fetch(`${API_URL}/admin/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-PUBLIC-KEY': PUBLIC_KEY,
    },
    body: JSON.stringify({ pin, password }),
  })

  const result = await response.json()

  if (result.status) {
    adminToken = result.data.admin.accessToken
    sessionStorage.setItem('crownvote_admin_token', adminToken)
  }

  return result
}

function getAdminHeaders() {
  if (!shouldShowAdminLoginForm()) {
    return {}
  }

  return {
    'X-ADMIN-TOKEN': adminToken,
  }
}

async function loginVoter({ token, password }) {
  const payload = { token }

  if (shouldShowVoterPasswordField()) {
    payload.password = password
  }

  const response = await fetch(`${API_URL}/voter/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-PUBLIC-KEY': PUBLIC_KEY,
      ...getAdminHeaders(),
    },
    body: JSON.stringify(payload),
  })

  const result = await response.json()

  if (result.status) {
    voterToken = result.data.accessToken.value
    sessionStorage.setItem('crownvote_voter_token', voterToken)
  }

  return result
}

async function submitVotes(selections) {
  const response = await fetch(`${API_URL}/voter/submit-votes`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-PUBLIC-KEY': PUBLIC_KEY,
      ...getAdminHeaders(),
      Authorization: `Bearer ${voterToken}`,
    },
    body: JSON.stringify({ selections }),
  })

  return response.json()
}
```

---

# Starter kits

CrownVote API Voting starter kits are being prepared for quick editing and drop-in usage.

Planned starter kits:

- Remote Voting SPA
- In-Person Voting SPA
- Tailwind CDN single-file starter
- Plain JavaScript fetch integration

The starter kits should be available shortly.

---

# Recommended docs URLs

```txt
https://crownvote.com/docs
https://crownvote.com/docs/api
https://crownvote.com/docs/api/remote-voting
https://crownvote.com/docs/api/in-person-voting
https://crownvote.com/docs/api/starter-kits
https://crownvote.com/docs/api.md
https://crownvote.com/docs/llms.txt
```

# Version

```txt
v1
```
