For researchers

How to turn the API into an analysis dataset: what one row is, how to read everything you bought page by page as CSV or JSON, which fields say where a fact came from, and what to keep in mind when you interpret it.

Units of analysis

One row isRead it fromKeyDictionary
An endorsement: one endorser’s pick of one choiceGET /v1/endorsementsidEndorsement
A choice on the ballot: a candidate in a contest, or Yes or No on a measureGET /v1/contests (choices; one row each with format=csv)choices[].idChoice
A contest: one race or questionGET /v1/contestsidContest
A candidate, across every contest they run inGET /v1/candidatesidCandidate
An endorserGET /v1/endorsersid, slugEndorser
A candidate’s position on an issueGET /v1/issue-positionsidIssue position

Endorsements join to choices on choice.id and to contests on contest.id; choices carry their candidate’s id, which is the same person in every election. An endorser who ranks three candidates in a ranked-choice contest has three endorsements with rank 1 to 3; one who backs a slate in a vote-for-N contest has one endorsement per candidate with no rank. Count endorsers, not rows, when you ask how many backed a choice (choices[].endorsement_count already does).

Reading lists as CSV

The list endpoints (elections, coverage, contests, endorsers, endorsements, candidates, issue positions and officeholders) answer format=csv with the same page as CSV. Paging does not change: a page holds at most limit records, and the cursor for the next one comes in the X-Next-Cursor header (and as a Link header with rel="next") until the last page, which has neither. The columns of each CSV, and the JSON field each is copied from, are in the data dictionaries, and there is a sample of each.

curl -D - "https://api.ballotdata.ai/v1/endorsements?elections=ca-2026-11-03-general&format=csv&limit=500" \
  -H "Authorization: Bearer $BALLOTDATA_API_KEY"
  • Lines end with CRLF and fields are quoted as in RFC 4180. An empty cell is a null or a value that doesn’t apply.
  • Booleans are true and false; dates are YYYY-MM-DD; percentages run from 0 to 100.
  • A list of plain values (a measure’s topics, source URLs) is one cell joined with ; .
  • A cell that starts with =, +, - or @ gets a leading ' so a spreadsheet doesn’t run it as a formula.
  • Contests come one row per choice and candidates one row per contest they are in, so a page can have more rows than limit.

There is no download of the whole dataset. Read what you need by election or state, a page at a time; one election at once is GET /v1/elections/{slug}/export. To keep a copy current, ask GET /v1/endorsements for changed_since your last read, or read GET /v1/changes, which also reports removals.

In R

library(httr2)
library(readr)
library(dplyr)

# Every page of a list as one data frame. Each page is a CSV; the next page's
# cursor comes in the X-Next-Cursor header until the last page.
ballotdata_csv <- function(path, ...) {
  pages <- list()
  cursor <- NULL
  repeat {
    resp <- request("https://api.ballotdata.ai") |>
      req_url_path(path) |>
      req_url_query(..., format = "csv", limit = 500, cursor = cursor) |>
      req_auth_bearer_token(Sys.getenv("BALLOTDATA_API_KEY")) |>
      req_retry(max_tries = 5) |>
      req_perform()
    pages[[length(pages) + 1]] <- read_csv(resp_body_string(resp), col_types = cols(.default = "c"))
    cursor <- resp_header(resp, "X-Next-Cursor")
    if (is.null(cursor)) break
  }
  bind_rows(pages)
}

endorsements <- ballotdata_csv("/v1/endorsements", elections = "ca-2026-11-03-general")
choices      <- ballotdata_csv("/v1/contests", elections = "ca-2026-11-03-general", limit = 200)

# One row per choice with how many labor endorsers backed it and how it did.
labor <- ballotdata_csv("/v1/endorsements", elections = "ca-2026-11-03-general", categories = "labor")
choices |>
  left_join(count(labor, choice_id, name = "labor_endorsements"), by = "choice_id") |>
  mutate(labor_endorsements = coalesce(labor_endorsements, 0L)) |>
  select(contest_title, choice_label, incumbent, labor_endorsements, choice_vote_pct, choice_winner)

In Python

import io
import os

import httpx
import pandas as pd


def ballotdata_csv(path: str, **params) -> pd.DataFrame:
    """Every page of a list as one DataFrame (format=csv, paged by X-Next-Cursor)."""
    pages, cursor = [], None
    with httpx.Client(
        base_url="https://api.ballotdata.ai",
        headers={"Authorization": f"Bearer {os.environ['BALLOTDATA_API_KEY']}"},
        timeout=60,
    ) as http:
        while True:
            query = {"limit": 500, **params, "format": "csv"}
            if cursor:
                query["cursor"] = cursor
            resp = http.get(path, params=query)
            resp.raise_for_status()
            pages.append(pd.read_csv(io.StringIO(resp.text), dtype=str))
            cursor = resp.headers.get("X-Next-Cursor")
            if not cursor:
                return pd.concat(pages, ignore_index=True)


endorsements = ballotdata_csv("/v1/endorsements", elections="ca-2026-11-03-general")
choices = ballotdata_csv("/v1/contests", elections="ca-2026-11-03-general", limit=200)

# A choice-by-endorser matrix: 1 where the endorser backed the choice.
matrix = (
    endorsements.assign(endorsed=1)
    .pivot_table(index="choice_id", columns="endorser_slug", values="endorsed", fill_value=0)
)
dataset = choices.merge(matrix, on="choice_id", how="left").fillna(0)

The client libraries page the JSON for you; the sample apps have a runnable version of both scripts.

Outcomes

Each choice’s result has votes, vote_pct and winner, and each contest’s result has its status (unofficial or certified), total_votes, where it came from (source_kind: official, compiled or party) and the source’s URL. Until a source publishes a result both are null. For analysis:

  • Filter on result_statuses=certified when counts must be final; unofficial counts are read again every day.
  • winner is null when the contest isn’t decided or the source doesn’t say, for example before a runoff. In a primary, true means the candidate advanced.
  • A party result names a winner without counts, so votes and vote_pct are null.
  • Choices’ votes can add up to less than total_votes: write-ins and other lines are counted apart (result.other_choices).
  • Ranked-choice contests carry their rounds in result.rcv_rounds where the office publishes them, and result.notes says what the counts are when it isn’t plain.
  • unopposed, race_type, partisan, office and each choice’s incumbent are the usual controls. incumbent is null where we don’t know, never a guess.

Where each fact came from

  • Every endorsement has the source_url it was published at, and published_on where the page gives a date. Many endorsers publish a slate with no date, so published_on is often null; don’t read null as late.
  • added_by says how an endorsement got here: automatic (our watch of the endorser’s own page), staff or data_load.
  • stage says whether an endorsement is for a primary, a general election or a runoff. An endorser who backs the same candidate in both has one endorsement in each election.
  • GET /v1/endorsements/{id}/history and GET /v1/changes show when an endorsement was added, changed or withdrawn.
  • Contests are as printed on the ballot. Where a state leaves unopposed races off the ballot, they aren’t here either.
  • Issue positions list their sources, each with its kind (a voting record, the candidate’s site, a questionnaire, news). A missing position means none is recorded, not that the candidate has none.

District, candidate and endorser data

The variables published studies of endorsements control for come with the records they describe, through the same paged endpoints. Each value carries its source and the date we read it, and a null means we have none, never zero or no. Their dictionaries are under Research data.

FieldWhat it isSource and license
district.partisanshipThe presidential vote in the contest’s district in 2020 and 2024, with the Democratic share of the two-party vote: the district’s lean.FEC official results (U.S. government work); MIT Election Data and Science Lab county returns (CC0); California Secretary of State district supplements.
district.demographicsAmerican Community Survey 2020-2024 estimates for the district: population, citizen voting-age population, income, age, education, race and ethnicity, nativity, home ownership, poverty.U.S. Census Bureau (public domain). This product uses Census Bureau data but is not endorsed or certified by the Census Bureau.
result.turnoutRegistered voters and ballots cast in the contest’s jurisdiction (a state, county, city or town) for the election: the denominator for turnout and roll-off.U.S. Election Assistance Commission, Election Administration and Voting Survey 2020-2024 (U.S. government work); state election offices for 2026.
first_seen_on, date_sourceThe earliest date an endorsement can be shown to have been public, and where that date came from, for timing studies.The date printed on the endorsement’s own page or press release.
background on candidatesPrior elected office (and which), gender and military service, each with its source.Our results; Open States (CC0); the Congressional Directory (U.S. government work); congress-legislators (CC0); the candidate’s own website.
ideology on candidatesThe candidate’s DIME CFscore, a left-right estimate from who gives money to whom.Bonica, DIME public version 4.0, Stanford University Libraries, ODC-BY 1.0: cite it as below.
facts on endorsersFEC committee ids, IRS employer id and tax-exemption section, Department of Labor file number and union membership, founding year, parent organization.FEC, IRS and Department of Labor files (U.S. government works); the endorser’s own About page.
  • With format=csv, the main values are columns: GET /v1/contests has district_pres_2024_dem_two_party_pct, the district_ demographics and turnout_ballots_cast; GET /v1/candidates has held_elected_office, gender, veteran and dime_cfscore; GET /v1/endorsers has ein, union_members and the other facts; GET /v1/endorsements has first_seen_on.
  • Partisanship, demographics and turnout are only for districts whose area an official source reports: states, counties, congressional and legislative districts, and the places and school districts the Census Bureau publishes. Most wards, council districts and special districts have none, and we never estimate them.
  • Candidate background is never inferred from a name or a photo, and a null means unknown, not no. We don’t collect race, ethnicity, religion or sexual orientation.
  • An ideology score is its authors’ estimate, not a fact about the person, and neither an endorsement nor a rating. Only DIME is included: Voteview DW-NOMINATE and Shor-McCarty scores are left out because their terms don’t allow commercial use. If you use dime_cfscore, cite: Bonica, Adam. 2024. Database on Ideology, Money in Politics, and Elections: Public version 4.0 [Computer file]. Stanford, CA: Stanford University Libraries. https://data.stanford.edu/dime.

Endorser track records

GET /v1/endorsers/{slug}/record counts how an endorser’s picks did: contests, picks, wins, losses, win rate and average vote share, in total and by contest kind, stage and election. Only top picks count: a ranked-choice first choice, every candidate of a slate, the Yes or No on a measure.

A win rate describes whom an endorser picks. It is not an estimate of the endorsement’s effect: endorsers favor incumbents and likely winners, so the record counts picks of incumbents and of non-incumbents apart, and a serious estimate needs a comparison you design (close contests, endorsers’ own switches, timing).

Agreement between endorsers

GET /v1/endorsers/{slug}/agreement compares one endorser with others over the contests both took a position in: contests in common, how many they agreed on, partly agreed on (overlapping slates) and disagreed on, and the rate. Use with for named endorsers, categories to compare with, say, only newspapers, and min_shared to set how many contests in common a pair needs. For a full co-endorsement network, read the endorsements and build the matrix yourself, as in the Python sample above.

Joining other data

FieldJoins to
ids.fec on candidatesFederal Election Commission candidate files; finance on GET /v1/candidates/{id} already has the cycle’s totals.
ids.bioguide on candidatesCongress’s Biographical Directory id, used by roll-call and legislation datasets.
district.ocd_idOpen Civic Data division ids, which encode the state and the district number or place.
state and fips (GET /v1/states)USPS and FIPS state codes, for Census and election-administration data.
facts.fec_committee_ids, facts.ein, facts.olms_file_number on endorsersFEC committee data (contributions, independent expenditures), IRS filings (Form 990) and Department of Labor union reports.
ideology.scores[].person_key on candidatesThe DIME recipients file, by FEC candidate id.
person_id on officeholdersOpen States person ids.

Limits to keep in mind

  • Coverage differs by state and by level of government: GET /v1/coverage?upcoming=false counts elections, contests, endorsers and endorsements per state, and contest_count, endorsement_count and result_count on each election show how full it is. A contest with no endorsements may be one nobody endorsed in or one whose endorsers we haven’t found.
  • Endorsements come from endorsers’ own pages and from the endorsement lists candidates publish; source_url shows which. An endorsement that was never published on the web is missing.
  • Interest-group scorecards and ratings are not endorsements and are never turned into them.
  • Past elections (details.historical) have results and the endorsements we could still find on the open web; older endorsement lists are less complete than this year’s.
  • Your key reads what your account bought, so counts are within that.

Citing the data

Name BallotData, the API version (info.version in /openapi.json) and the date you read the data, for example: BallotData API, version 1.8.0, api.ballotdata.ai, read November 14, 2026. Results and endorsements change until an election is certified, so record the date with your copy. What you may publish from the data is set by the terms of service. Data we took from others keeps its own attribution: cite DIME as above when you use dime_cfscore, and say that Census figures come from the Census Bureau, which does not endorse your work.

For researchers · BallotData API