Cross-voting analysis REST API for programmatic access to election data.
The Right Steps Victory Election Analytics API provides programmatic access to cross-voting analysis data. Use it to query how voters who supported a specific candidate in one race voted in other races on the same ballot.
The API is read-only and returns JSON responses. All endpoints require authentication via an API key.
All API requests must include a valid API key in the X-API-Key HTTP header:
X-API-Key: your_api_key_here
API keys are stored as a salted one-way hash (werkzeug generate_password_hash, scrypt). Only the first 8 characters (prefix) are kept in plaintext for identification. Keep your full key secure—it cannot be recovered if lost.
Keys can be deactivated or expired by an administrator at any time. Usage is tracked per request.
https://victory.hardingps.com/api/v1/election ← ballot / cross-voting data
https://victory.hardingps.com/api/v1/turnout ← daily turnout data
Endpoint paths are relative to the base URL for their section. Both use the same API key.
/datasets
Returns all completed election datasets available for analysis.
Response:
{
"datasets": [
{
"id": 1,
"name": "2024 Primary Election",
"description": "Primary election CVR data",
"election_date": "2024-03-19",
"election_type": "primary",
"total_records": 150000,
"status": "completed"
}
]
}
/datasets/{dataset_id}/contests
Returns all contests and their choices for a specific dataset. Use this to discover valid contest and choice IDs for cross-voting queries.
| Parameter | Type | In | Description |
|---|---|---|---|
dataset_id | integer | path | The ID of the dataset |
Response:
{
"dataset_id": 1,
"dataset_name": "2024 Primary Election",
"contests": [
{
"id": 1,
"name": "Presidential Race",
"position": 0,
"choices": [
{"id": 1, "name": "Candidate A", "vote_count": 50000},
{"id": 2, "name": "Candidate B", "vote_count": 45000}
]
}
]
}
/datasets/{dataset_id}/analysis/cross-voting
Analyzes how voters who chose specific candidates in source races voted in target comparison races. Supports querying multiple source contest/choice pairs in a single request.
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
dataset_id | integer | path | Yes | The ID of the dataset |
source_contest_ids | string | query | Yes* | Comma-separated source contest IDs (e.g., 1,2,3) |
source_choice_ids | string | query | Yes* | Comma-separated source choice IDs matching each contest (e.g., 10,20,30) |
compare_contest_ids | string | query | Yes | Comma-separated target contest IDs to compare against (e.g., 4,5) |
*Legacy parameters primary_contest_id and primary_choice_id are also accepted for single-source requests.
Single-source response:
{
"dataset_id": 1,
"dataset_name": "2024 Primary Election",
"primary_contest": {"id": 1, "name": "Presidential Race"},
"primary_choice": {"id": 10, "name": "Candidate A"},
"total_matching_voters": 50000,
"comparisons": [
{
"contest_id": 4,
"contest_name": "Senate Race",
"distribution": [
{"choice": "Senator X", "count": 30000, "percentage": 60.0},
{"choice": "Senator Y", "count": 18000, "percentage": 36.0},
{"choice": "UNDERVOTE", "count": 2000, "percentage": 4.0}
]
}
]
}
Multi-source response:
{
"dataset_id": 1,
"dataset_name": "2024 Primary Election",
"results": [
{
"source_contest": {"id": 1, "name": "Presidential Race"},
"source_choice": {"id": 10, "name": "Candidate A"},
"total_matching_voters": 50000,
"comparisons": [
{
"contest_id": 4,
"contest_name": "Senate Race",
"distribution": [
{"choice": "Senator X", "count": 30000, "percentage": 60.0}
]
}
]
}
]
}
Live turnout for an election in progress, built from the Florida Division of Elections
daily vote-by-mail and early voting files and compared against the voter file. Base path
/api/v1/turnout, same X-API-Key as above.
"provisional": true. It is kept separate from certified voter history and
never merged into it.
includes_protected_voters: true). Their date of birth and city
are withheld by the state, so they fall in the unknown age
bracket and match no cities filter. They are excluded from
/voters, which returns per-voter records.
Accepted by /summary, /trend and /voters.
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
election | integer | query | No | FVRS election number. Defaults to the currently tracked election. |
counties | string | query | No | Comma-separated 3-letter county codes, e.g. DUV,CLA. Omit for statewide. |
parties | string | query | No | Comma-separated party codes, e.g. DEM,REP,NPA. |
races | string | query | No | Comma-separated race codes (1–7, 9). See race_label in responses. |
genders | string | query | No | Sex: comma-separated F, M, U. |
cities | string | query | No | Comma-separated residential city names as stored in the voter file, e.g. JACKSONVILLE. |
methods | string | query | No | Prior voting habit, comma-separated: mail, early, polls (election day), never. This is the voter's dominant historical method across past elections — not how they voted in this one. |
history_elections | string | query | No | Restrict the universe to voters who took part in past elections. YYYY-MM-DD|TYPE tokens, e.g. 2024-11-05|GEN. Repeat the parameter or comma-separate for several; both forms work. Only ballots that counted (history codes A/E/Y) qualify. List available elections with /api/v1/turnout/past-elections. |
history_logic | string | query | No | any (default) = voted in at least one of them; all = voted in every one. |
age_brackets | string | query | No | Comma-separated age brackets: 18-24, 25-34, 35-44, 45-54, 55-64, 65+, unknown. Age is measured as of election day, not today, so a bracket means the same thing on every screen and in every day's stored rollup. unknown is almost entirely voters with a public-records exemption, whose date of birth is withheld in the state file; they are counted in the totals. The brackets partition the universe. |
tiers | string | query | No | Participation tier (propensity), comma-separated scores — e.g. tiers=4,5 for high-propensity voters. Tiers are ranges over each voter's lifetime counted-ballot total and are configurable, so read the current scheme and its labels from /api/v1/turnout/tiers rather than hard-coding scores. The tiers partition the universe exactly: selecting all of them equals selecting none. |
district_type | string | query | No | cong, senate, house, county_comm or school. |
district_value | string | query | Conditional | The district number. Required when district_type is given. |
universe | string | query | No | Denominator: active (default), all, or vbm_requested. |
3 exists in 64 Florida counties. Requesting one without exactly one
counties value returns 400 rather than silently adding unrelated
districts together. Congressional, State Senate and State House numbers are unique
statewide and need no county.
/api/v1/turnout/elections
Elections available for turnout reporting, with how fresh each one's data is.
Response:
{
"elections": [
{
"election_number": 49893,
"name": "2026 Primary Election",
"election_date": "2026-08-18",
"election_type": "PRI",
"tracked": true,
"latest_data_date": "2026-08-12",
"last_vbm_fetch": "2026-08-12T14:54:51",
"last_ev_fetch": "2026-08-12T14:52:09"
}
],
"provisional": true
}
/api/v1/turnout/summary
Totals plus breakdowns by party, race, age bracket and number of prior elections voted. Accepts every shared filter above.
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
include_cells | boolean | query | No | true adds the full party×race×age×prior-votes grid. Tens of thousands of rows statewide, so opt-in. |
Response:
{
"election": { "election_number": 49893, "name": "2026 Primary Election",
"election_date": "2026-08-18", "election_type": "PRI" },
"as_of_date": "2026-08-12",
"source": "rollup",
"filters": { "universe": "active", "universe_label": "Active registered voters",
"counties": null, "parties": null, "races": null,
"district_type": null, "district_value": null },
"totals": {
"universe": 13349100, "voted": 1892230, "not_voted": 11456870,
"turnout_rate": 14.17,
"voted_vbm": 860286, "voted_ev": 320665,
"vbm_requested": 1976612, "vbm_outstanding": 1090298,
"vbm_return_rate": 43.52
},
"by_party": [ { "key": "REP", "label": "Republican", "universe": 5527681,
"voted": 856308, "not_voted": 4671373,
"turnout_rate": 15.49 } ],
"by_race": [ { "key": "5", "label": "White, Not Hispanic", "universe": 8233334,
"voted": 884741, "turnout_rate": 10.75 } ],
"by_sex": [ { "key": "F", "label": "Female", "universe": 7069885,
"voted": 644312, "turnout_rate": 9.11 } ],
"by_age": [ { "key": "65+", "label": "65+", "universe": 4342196,
"voted": 801283, "turnout_rate": 18.45 } ],
"by_prior_participation": [ { "key": "Voted 11+ times", "label": "Voted 11+ times",
"universe": 1725649, "voted": 590928,
"turnout_rate": 34.24 } ],
"provisional": true
}
source tells you which path answered the request.
rollup means the pre-aggregated daily table — county, party, race
and sex are all columns of its grain, so filtering on them stays on this fast path.
live means it was computed against current data, which happens when you
filter by cities or a district (neither is in the rollup grain), or when
the key's user has a district-limited voter scope, since the rollup is aggregated
statewide and cannot be scope-filtered.
Both paths return the same numbers; live is simply slower and reflects
the moment of the request rather than as_of_date.
/api/v1/turnout/trend
Turnout by day. Accepts every shared filter.
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
group_by | string | query | No | party or age for segmented series. Ignored when a party/race/district filter is applied. |
Response:
{
"election": { "election_number": 49893, "name": "2026 Primary Election" },
"source": "rollup",
"group_by": null,
"points": [
{ "as_of_date": "2026-08-12", "universe": 13349100, "voted": 1180951,
"voted_vbm": 860286, "voted_ev": 320665, "turnout_rate": 8.85 }
],
"provisional": true
}
/api/v1/turnout/voters
Individual voters with their prior participation and this election's ballot status. Accepts every shared filter, plus:
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
mode | string | query | No | not_voted (default, the chase list), vbm_outstanding (mail ballot sent, not returned), or voted. |
limit | integer | query | No | Page size, default 100, maximum 1000. |
offset | integer | query | No | Rows to skip, default 0. |
Response:
{
"election": { "election_number": 49893, "name": "2026 Primary Election" },
"mode": "not_voted",
"pagination": { "total": 19625, "limit": 100, "offset": 0,
"returned": 100, "has_more": true },
"voters": [
{
"state_voter_id": "100008211",
"name_last": "Smith", "name_first": "Jordan",
"party": "REP", "race": "5", "race_label": "White, Not Hispanic",
"sex": "M", "sex_label": "Male",
"birth_date": "1948-03-11",
"county": "DUV", "city": "JACKSONVILLE", "precinct": "903",
"districts": { "congressional": "5", "state_senate": "4",
"state_house": "12", "county_commission": "3",
"school_board": "5" },
"prior_participation": { "total_elections": 15, "general": 5,
"primary": 4, "presidential_preference": 2,
"other": 4, "last_voted": "2024-11-05" },
"this_election": { "voted": false, "vote_method": null,
"voted_date": null, "vbm_status_code": "P",
"vbm_requested_date": "2026-07-27",
"vbm_returned_date": null,
"early_voted_date": null,
"early_voting_site": null }
}
],
"provisional": true
}
Results are restricted to the voters the key's assigned user may see. A key with no
assigned user returns 403.
/api/v1/turnout/method-migration
How voters are casting a ballot this election against their usual method.
Accepts every shared filter. Each row carries voted (all
methods) and turnout_rate alongside the by_mail /
early split, and voted == by_mail + early.
not_yet_voted includes
everyone who intends to vote at the polls — it does not mean they will
not vote.
Per-row semantics differ, because the source can see some things and not others:
mail / early — consistent and
switched both observable and summing to voted.polls — every ballot here is by definition a changed
method, so switched == voted. consistent is
0 with consistent_observable: false: voting at the
polls is invisible in this source until certification, so that zero means
none observed, not none.never — registrants who had not voted before. A ballot
from one is not a switch but a first_time vote;
consistent and switched are null.Response:
{
"rows": [
{ "prior": "mail", "prior_label": "Mostly votes by mail",
"universe": 3252808, "by_mail": 950496, "early": 93414,
"not_yet_voted": 2208898, "voted": 1043910, "turnout_rate": 32.09,
"consistent": 950496, "switched": 93414, "switched_rate": 8.95 },
{ "prior": "polls", "prior_label": "Mostly votes on election day",
"universe": 3587897, "by_mail": 44810, "early": 130642,
"not_yet_voted": 3412445, "voted": 175452,
"consistent": 0, "consistent_observable": false,
"switched": 175452, "switched_rate": 100.0, "first_time": null },
{ "prior": "never", "prior_label": "No prior voting history",
"universe": 1853758, "voted": 50798,
"consistent": null, "switched": null, "first_time": 50798 }
],
"totals": { "universe": 13349100, "by_mail": 1088521,
"early": 800175, "not_yet_voted": 11460404,
"switched": 342759, "first_time": 50798 },
"provisional": true
}
/api/v1/turnout/districts
With no parameters, lists the district types and which require a county. With
district_type, lists that type's available district values.
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
district_type | string | query | No | Omit to list all types. |
counties | string | query | Conditional | Exactly one county code, required for county_comm and school. |
Response:
{
"district_type": "school",
"label": "School Board",
"county_required": true,
"counties": ["DUV"],
"values": ["1", "2", "3", "4", "5", "6", "7"]
}
/api/v1/turnout/benchmark
The same filtered segment's turnout in a past, certified election, so a live
rate can be read against something. Accepts every filter the summary does,
plus benchmark.
Two things to understand before quoting the number:
not_yet_registered. Statewide this is ~2.4M voters
for the 2022 primary; including them would understate that election by
about five points.| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
benchmark | string | query | Yes | A YYYY-MM-DD|TYPE token from /api/v1/turnout/past-elections. |
| Plus every filter listed under Summary. | ||||
Response:
{
"benchmark": {"token": "2022-08-23|PRI", "label": "2022 Primary", "date": "2022-08-23", "type": "PRI"},
"totals": {"universe": 551562, "voted": 168785, "turnout_rate": 30.6},
"by_party": {"DEM": {"universe": 220117, "voted": 74396, "turnout_rate": 33.8}},
"not_yet_registered": 92256
}
/api/v1/turnout/tiers
The current propensity scheme, for use with the tiers filter.
Takes no parameters. The scheme is configurable — ranges,
labels and scores can all be changed by an administrator — so read it here
rather than hard-coding scores, which would silently come to mean something
else after an edit. The tiers partition the universe: selecting all of them
returns the same numbers as selecting none.
Response:
{
"tiers": [
{"score": 0, "label": "Never voted", "min_votes": 0, "max_votes": 0},
{"score": 1, "label": "Voted 1-2 times", "min_votes": 1, "max_votes": 2},
{"score": 2, "label": "Voted 3-5 times", "min_votes": 3, "max_votes": 5},
{"score": 4, "label": "Voted 6-10 times", "min_votes": 6, "max_votes": 10},
{"score": 5, "label": "Voted 11+ times", "min_votes": 11, "max_votes": null}
]
}
/api/v1/turnout/past-elections
Elections usable as a history_elections universe filter, as
YYYY-MM-DD|TYPE tokens. Statewide elections are always returned;
local ones only when a single county is named, since 394 exist statewide and
almost none are meaningful outside their own county.
| Parameter | Type | In | Required | Description |
|---|---|---|---|---|
counties | string | query | No | One county code; required to receive that county's local elections. |
# List all datasets
curl -H "X-API-Key: your_api_key" \
https://victory.hardingps.com/api/v1/election/datasets
# Get contests for a dataset
curl -H "X-API-Key: your_api_key" \
https://victory.hardingps.com/api/v1/election/datasets/1/contests
# Cross-voting analysis (single source)
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/election/datasets/1/analysis/cross-voting?\
source_contest_ids=1&source_choice_ids=10&compare_contest_ids=4,5"
# Cross-voting analysis (multiple sources)
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/election/datasets/1/analysis/cross-voting?\
source_contest_ids=1,2&source_choice_ids=10,20&compare_contest_ids=4,5"
# --- Turnout ---
# Statewide turnout summary for the current election
curl -H "X-API-Key: your_api_key" \
https://victory.hardingps.com/api/v1/turnout/summary
# Discover the propensity tiers, then filter to the high-propensity ones
curl -H "X-API-Key: your_api_key" \
https://victory.hardingps.com/api/v1/turnout/tiers
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/turnout/summary?election=49893&tiers=4,5"
# One congressional district
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/turnout/summary?election=49893\
&district_type=cong&district_value=5"
# School board district -- county is required, these numbers repeat statewide
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/turnout/summary?election=49893\
&counties=DUV&district_type=school&district_value=3"
# Chase list: registered Democrats in Duval who have not voted yet
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/turnout/voters?election=49893\
&counties=DUV&parties=DEM&mode=not_voted&limit=500"
# Turnout among women in one city
curl -H "X-API-Key: your_api_key" \
"https://victory.hardingps.com/api/v1/turnout/summary?election=49893\
&counties=DUV&cities=JACKSONVILLE&genders=F"
import requests
API_KEY = "your_api_key"
BASE_URL = "https://victory.hardingps.com/api/v1/election"
headers = {"X-API-Key": API_KEY}
# List datasets
response = requests.get(f"{BASE_URL}/datasets", headers=headers)
datasets = response.json()
# Get contests for a dataset
dataset_id = 1
response = requests.get(
f"{BASE_URL}/datasets/{dataset_id}/contests",
headers=headers
)
contests = response.json()
# Cross-voting analysis
params = {
"source_contest_ids": "1",
"source_choice_ids": "10",
"compare_contest_ids": "4,5"
}
response = requests.get(
f"{BASE_URL}/datasets/{dataset_id}/analysis/cross-voting",
headers=headers,
params=params
)
analysis = response.json()
print(analysis)
const API_KEY = "your_api_key";
const BASE_URL = "https://victory.hardingps.com/api/v1/election";
const headers = { "X-API-Key": API_KEY };
// List datasets
const datasets = await fetch(`${BASE_URL}/datasets`, { headers })
.then(res => res.json());
// Get contests
const datasetId = 1;
const contests = await fetch(
`${BASE_URL}/datasets/${datasetId}/contests`, { headers }
).then(res => res.json());
// Cross-voting analysis
const params = new URLSearchParams({
source_contest_ids: "1",
source_choice_ids: "10",
compare_contest_ids: "4,5"
});
const analysis = await fetch(
`${BASE_URL}/datasets/${datasetId}/analysis/cross-voting?${params}`,
{ headers }
).then(res => res.json());
console.log(analysis);
The API uses standard HTTP status codes. Errors return a JSON body with error and message fields.
| Code | Meaning | Common Cause |
|---|---|---|
| 200 | Success | Request completed successfully |
| 400 | Bad Request | Missing or invalid query parameters |
| 401 | Unauthorized | Missing, invalid, or expired API key |
| 404 | Not Found | Dataset or contest ID does not exist |
| 500 | Server Error | Unexpected internal error |
Error response format:
{
"error": "Bad Request",
"message": "Missing required parameter: source_contest_ids"
}