Overview

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.

Getting an API Key: Contact your Super Admin or Campaign Manager to generate an API key for your account. Keys are managed within the Election Analytics section of the platform.

Authentication

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.

Base URL

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.

Endpoints

GET

List Datasets

/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"
    }
  ]
}
GET

Get Dataset Contests

/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.

ParameterTypeInDescription
dataset_idintegerpathThe 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}
      ]
    }
  ]
}
GET

Cross-Voting Analysis

/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.

ParameterTypeInRequiredDescription
dataset_idintegerpathYesThe ID of the dataset
source_contest_idsstringqueryYes*Comma-separated source contest IDs (e.g., 1,2,3)
source_choice_idsstringqueryYes*Comma-separated source choice IDs matching each contest (e.g., 10,20,30)
compare_contest_idsstringqueryYesComma-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}
          ]
        }
      ]
    }
  ]
}

Turnout Tracking

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.

This data is provisional. It comes from the state's daily file before canvassing and certification, and will not match certified results — a returned mail ballot can still be rejected. Every response carries "provisional": true. It is kept separate from certified voter history and never merged into it.
Protected voters are counted. Voters with a public-records exemption are included in the universe, rates and breakdowns (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.

Shared filter parameters

Accepted by /summary, /trend and /voters.

ParameterTypeInRequiredDescription
electionintegerqueryNoFVRS election number. Defaults to the currently tracked election.
countiesstringqueryNoComma-separated 3-letter county codes, e.g. DUV,CLA. Omit for statewide.
partiesstringqueryNoComma-separated party codes, e.g. DEM,REP,NPA.
racesstringqueryNoComma-separated race codes (17, 9). See race_label in responses.
gendersstringqueryNoSex: comma-separated F, M, U.
citiesstringqueryNoComma-separated residential city names as stored in the voter file, e.g. JACKSONVILLE.
methodsstringqueryNoPrior 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_electionsstringqueryNoRestrict 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_logicstringqueryNoany (default) = voted in at least one of them; all = voted in every one.
age_bracketsstringqueryNoComma-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.
tiersstringqueryNoParticipation 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_typestringqueryNocong, senate, house, county_comm or school.
district_valuestringqueryConditionalThe district number. Required when district_type is given.
universestringqueryNoDenominator: active (default), all, or vbm_requested.
County commission and school board districts require a county. Those districts are numbered per county and the numbers repeat statewide — school district 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.
GET

List Turnout Elections

/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
}
GET

Turnout Summary

/api/v1/turnout/summary

Totals plus breakdowns by party, race, age bracket and number of prior elections voted. Accepts every shared filter above.

ParameterTypeInRequiredDescription
include_cellsbooleanqueryNotrue 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.

GET

Turnout Trend

/api/v1/turnout/trend

Turnout by day. Accepts every shared filter.

ParameterTypeInRequiredDescription
group_bystringqueryNoparty 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
}
GET

Voters

/api/v1/turnout/voters

Individual voters with their prior participation and this election's ballot status. Accepts every shared filter, plus:

ParameterTypeInRequiredDescription
modestringqueryNonot_voted (default, the chase list), vbm_outstanding (mail ballot sent, not returned), or voted.
limitintegerqueryNoPage size, default 100, maximum 1000.
offsetintegerqueryNoRows 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.

GET

Method Migration

/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.

Election-day voting is not in this data. The state publishes vote-by-mail and early voting only, so 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 / earlyconsistent 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
}
GET

Districts

/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.

ParameterTypeInRequiredDescription
district_typestringqueryNoOmit to list all types.
countiesstringqueryConditionalExactly 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"]
}
GET

Benchmark

/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:

  • The denominator excludes voters registered after that election — they could not have voted in it. The count left out is returned as not_yet_registered. Statewide this is ~2.4M voters for the 2022 primary; including them would understate that election by about five points.
  • It is measured over voters registered today, not the electorate of the day. Anyone since removed from the roll is absent, so this will not match the published result for that election and is not meant to.
ParameterTypeInRequiredDescription
benchmarkstringqueryYesA 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
}
GET

Participation tiers

/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}
  ]
}
GET

Past elections

/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.

ParameterTypeInRequiredDescription
countiesstringqueryNoOne county code; required to receive that county's local elections.

Code Examples

# 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);

Error Handling

The API uses standard HTTP status codes. Errors return a JSON body with error and message fields.

CodeMeaningCommon Cause
200SuccessRequest completed successfully
400Bad RequestMissing or invalid query parameters
401UnauthorizedMissing, invalid, or expired API key
404Not FoundDataset or contest ID does not exist
500Server ErrorUnexpected internal error

Error response format:

{
  "error": "Bad Request",
  "message": "Missing required parameter: source_contest_ids"
}