ballotdata
The official Python client for the BallotData API: U.S. ballots (elections, contests and their choices), endorsers and endorsements, officeholders, and the live Endorsement Stream of changes.
- Covers every endpoint of the API, with type hints for every response and parameter.
- Sync and async (
asyncio) clients with the same methods. - One dependency (httpx). Python 3.10 or newer.
- Handles the details: retries with
Retry-After, timeouts, typed errors, paging, and a stream that reconnects and resumes where it stopped.
pip install ballotdata
from ballotdata import BallotData
client = BallotData() # reads BALLOTDATA_API_KEY
contest = client.contests.get(4410, categories="labor")
for position in contest["endorsements"]:
print(position["endorser"]["name"], [pick["label"] for pick in position["picks"]])
Contents
- Getting an API key
- Setting up the client
- What you can ask for
- Paging
- Ballots: contests, choices and voting rules
- Endorsers and endorsements
- The Endorsement Stream
- Polling instead of streaming
- Subscriptions
- Postbacks (signed webhooks)
- Errors
- Retries and timeouts
- Rate limits
- Async
- Types
- More documentation
Getting an API key
API accounts come with a purchase at ballotdata.net/pricing. Once it goes through, sign in at api.ballotdata.net and create a key in the console.
A key covers what your account bought. Asking about another state answers 403 outside_purchase.
Keys have scopes: data:read for the data endpoints, stream:read for the stream, polling and
reading subscriptions, and subscriptions:write to change subscriptions.
Keep keys on servers. Never put one in a browser page or a mobile app.
Setting up the client
import os
from ballotdata import BallotData
client = BallotData(
api_key=os.environ["BALLOTDATA_API_KEY"], # the default, so you can leave it out
timeout=30, # seconds to wait for each response (default 30)
max_retries=2, # retries for 429, 503, network errors (default 2)
)
| Argument | Default | What it does |
|---|---|---|
api_key |
BALLOTDATA_API_KEY |
Your key, ebk_…. |
base_url |
BALLOTDATA_API_URL, then https://api.ballotdata.net |
Where requests go. |
timeout |
30 |
Seconds to wait for each response. |
max_retries |
2 |
Retries after 429, 503, network errors and timeouts, and 500/502/504 on GET, PUT and DELETE. 0 turns retries off. |
http_client |
a new httpx.Client |
Your own httpx.Client (proxies, custom TLS, tests). You close it yourself. |
default_headers |
{} |
Extra headers on every request. |
on_response |
none | Called with a ResponseInfo (method, url, status, request_id, rate_limit) after every response. |
Every method also takes timeout, max_retries and extra_headers keyword arguments for that one
call. The client holds a connection pool: use it as a context manager (with BallotData() as client:) or call client.close() when you're done.
What you can ask for
| Method | Endpoint | Returns |
|---|---|---|
client.states.list() |
GET /v1/states |
list[State] |
client.coverage.list(upcoming=) |
GET /v1/coverage |
list[Coverage] |
client.elections.list(...) |
GET /v1/elections |
pages of ElectionSummary |
client.contests.list(...) |
GET /v1/contests |
pages of Contest |
client.contests.get(id, ...) |
GET /v1/contests/{id} |
ContestDetail (with every endorser's position) |
client.endorser_categories.list(lang=) |
GET /v1/endorser-categories |
list[EndorserCategory] |
client.endorsers.list(...) |
GET /v1/endorsers |
pages of Endorser |
client.endorsers.get(slug, lang=) |
GET /v1/endorsers/{slug} |
EndorserDetail |
client.endorsements.list(...) |
GET /v1/endorsements |
pages of Endorsement (the basics) |
client.endorsements.get(id, lang=) |
GET /v1/endorsements/{id} |
EndorsementDetail (the full record) |
client.officeholders.list(...) |
GET /v1/officeholders |
pages of Officeholder |
client.officeholders.get(person_id, lang=) |
GET /v1/officeholders/{id} |
OfficeholderDetail |
client.stream.connect(...) |
GET /v1/stream |
a live EndorsementStream |
client.events.list(...) |
GET /v1/events |
EventsPage |
client.events.follow(...) |
GET /v1/events (long polling) |
changes, forever |
client.subscriptions.list() |
GET /v1/subscriptions |
list[Subscription] |
client.subscriptions.create(name=, filter=) |
POST /v1/subscriptions |
Subscription |
client.subscriptions.get(id) |
GET /v1/subscriptions/{id} |
Subscription |
client.subscriptions.update(id, name=, filter=) |
PUT /v1/subscriptions/{id} |
Subscription |
client.subscriptions.delete(id) |
DELETE /v1/subscriptions/{id} |
None |
verify_postback_signature(), construct_postback() |
your endpoint | checks and parses postbacks |
Every argument of every method is listed in the reference. Arguments are
keyword-only and named as in the API, except from, a Python keyword, which is from_. List
arguments take a list or a comma-separated string: states=["CA", "NV"] and states="CA,NV" mean
the same. Ballot text comes in Spanish with lang="es" where the state publishes it.
Responses are plain dicts with the API's field names, typed with TypedDict, so the
API reference reads the same as your code.
Paging
List methods return the first page, a SyncPage. Its data holds the page's items and
next_cursor the cursor for the next page. Iterating the page walks every item on every page,
fetching pages as you go:
# One page
page = client.endorsements.list(states="CA", limit=500)
page.data, page.next_cursor
# Every item, across every page
for endorsement in client.endorsements.list(states="CA", limit=500):
save(endorsement)
# Every item in a list (pass a maximum for big lists)
everything = client.endorsers.list(categories="labor").to_list(max=1000)
# Page by page
for page in client.contests.list(elections="ca-2026-11-03-general").iter_pages():
print(len(page.data), page.next_cursor)
To page by hand, pass the last page's next_cursor as cursor, or call page.next_page().
Ballots: contests, choices and voting rules
A contest's vote_method says how it is voted, and its choices are in ballot order:
vote_method |
Meaning | Look at |
|---|---|---|
plurality |
Vote for one | votes_allowed (1) |
multi_seat |
Vote for up to N | votes_allowed |
ranked_choice |
Rank up to N | ranks_allowed, and each pick's rank |
approval |
Vote for as many as you like | |
yes_no |
A measure or retention question | each choice's measure_answer |
election = client.elections.list(states="CA", types="general", upcoming=True).data[0]
for contest in client.contests.list(elections=election["slug"]):
if contest["vote_method"] == "ranked_choice":
rule = f"rank up to {contest['ranks_allowed']}"
else:
rule = f"vote for {contest['votes_allowed']}"
print(f"{contest['title']} ({rule}):", ", ".join(c["label"] for c in contest["choices"]))
client.contests.get(id) adds every endorser's position. Each position's picks hold one pick for
vote-for-one, up to N for vote-for-N, the ranking for ranked choice, or Yes/No for a measure:
contest = client.contests.get(4410, categories=["labor", "editorial"])
for position in contest["endorsements"]:
marks = [f"{p['rank']}. {p['label']}" if p["rank"] else p["label"] for p in position["picks"]]
print(f"{position['endorser']['name']}: {' '.join(marks)}")
Endorsers and endorsements
# The kinds of endorser, for categories filters
categories = client.endorser_categories.list()
# Find endorsers by category, home state (or "national"), election, or name
unions = client.endorsers.list(categories="labor", states="CA", q="council").data
# One endorser and the elections they endorsed in
endorser = client.endorsers.get("sf-labor-council")
client.endorsements.list() returns one row per endorser and choice with the basics: the endorser,
contest and election (ids, slugs and names), the choice and its rank. It is the way to sync
everything. A ranked-choice endorsement of three candidates is three rows with ranks 1 to 3.
client.endorsements.get(id) has the full record: the statement and source, the endorser's details,
the contest with its district, election and voting rules, and the choice with its candidate.
e = client.endorsements.get(90211)
print(e["endorser"]["category"]["name"], e["contest"]["vote_method"], e["statement"], e["source_url"])
group_by_contest() turns rows back into positions:
from ballotdata import group_by_contest
rows = client.endorsements.list(endorsers="sf-labor-council", elections="ca-2026-11-03-general", limit=500)
for group in group_by_contest(rows):
print(group["contest"]["title"], [p["label"] for p in group["positions"][0]["picks"]])
The Endorsement Stream
client.stream.connect() opens GET /v1/stream and yields every endorsement change that matches
your filter (or saved subscription), within seconds of it happening.
with client.stream.connect(states=["CA", "NV"], categories="labor", cursor=load_cursor(), on_cursor=save_cursor) as stream:
for message in stream:
if message.type == "ready":
print("connected")
elif message.type == "endorsement":
event = message.event # event["type"]: endorsement.created | updated | deleted
print(event["type"], event["endorser"]["name"], event["choice"]["label"], event["contest"]["title"])
The stream keeps its place for you:
- Each change moves the cursor. Save it in
on_cursorand pass it back ascursorafter a restart to continue exactly where you stopped. Without a cursor the stream starts atfrom_="latest"(or"earliest", orsince=datetime). - It reconnects with the cursor after the API's hourly
reconnect, a dropped connection, a connection that goes silent for a minute (idle_timeout), or a 429/503 (afterRetry-After). Nothing is missed or repeated. checkpointevents move the cursor past changes your filter skipped. You don't see them; the cursor just moves.- If the cursor is older than the stream keeps, iteration raises
CursorExpiredError. Catch up withclient.endorsements.list()and start again withfrom_="latest". stream.close()ends the loop quietly, even from another thread. Leaving thewithblock closes the connection.
client.stream.endorsements(...) takes the same arguments and yields only the EndorsementMessages
(each with .event and .cursor).
The stream needs a key with stream:read and a purchase that includes the stream. The
stream guide covers syncing first, recovering from an expired cursor, running in
a background thread, and serverless functions.
Polling instead of streaming
Where a connection can't stay open (serverless functions, cron, some proxies), read the same changes
from GET /v1/events:
# One page; wait up to 20 s for something new
page = client.events.list(subscription="CA labor", cursor=cursor, wait=20)
# page["data"], page["next_cursor"], page["has_more"]
# Or forever
for message in client.events.follow(states="CA", cursor=load_cursor()):
handle(message.event)
save_cursor(message.cursor)
Subscriptions
A subscription is a saved filter. Use it by id or name as subscription= on the stream and on events.
sub = client.subscriptions.create(name="CA labor", filter={"categories": ["labor"], "states": ["CA"]})
subs = client.subscriptions.list()
client.subscriptions.update(sub["id"], name="CA labor", filter={"categories": ["labor"], "states": ["CA", "NV"]})
client.subscriptions.delete(sub["id"])
with client.stream.connect(subscription="CA labor") as stream:
...
A filter's fields combine with AND and the values within a field with OR: categories, endorsers,
endorser_states, states, elections, contest_kinds, levels, and types
(created, updated, deleted). An account can have up to 50.
Postbacks (signed webhooks)
With postbacks, BallotData POSTs each batch of changes to your HTTPS endpoint, signed with your account's signing secret. Check the signature against the raw body:
import os
from flask import Flask, request
from ballotdata import PostbackSignatureError, construct_postback
app = Flask(__name__)
@app.post("/ballotdata")
def ballotdata_postback():
try:
postback = construct_postback(
request.get_data(), request.headers.get("BallotData-Signature"), os.environ["BALLOTDATA_SIGNING_SECRET"]
)
except PostbackSignatureError:
return "", 401
for change in postback["changes"]:
handle(change) # change["id"] drops repeats
return "", 204
verify_postback_signature(raw_body, header, secret) returns True or False instead of raising.
sign_postback(raw_body, secret) makes a header for testing your endpoint. See the
postbacks guide for Django and FastAPI.
Errors
Every error the API answers with is an APIError with status, code (such as
outside_purchase), message and request_id. Subclasses let you catch by kind:
| Class | Status | When |
|---|---|---|
BadRequestError |
400 | A parameter isn't valid. |
AuthenticationError |
401 | No key, or a bad, revoked or expired one. |
PermissionDeniedError |
403 | Missing scope, IP allowlist, account not active, or data you haven't bought. |
NotFoundError |
404 | No such contest, endorser, officeholder or subscription. |
ConflictError |
409 | A subscription with that name exists. |
CursorExpiredError |
410 | The stream position is too old. |
RateLimitError |
429 | Over a rate limit, or too many open streams. Has retry_after. |
InternalServerError |
5xx | A problem at BallotData. |
APIConnectionError |
The API couldn't be reached. | |
APITimeoutError |
No answer within timeout (a kind of APIConnectionError). |
All of them derive from BallotDataError.
from ballotdata import APIError, NotFoundError
try:
contest = client.contests.get(contest_id)
except NotFoundError:
contest = None
except APIError as err:
print(err.code, err.message, err.request_id)
raise
Quote the request_id when you write to sales@ballotdata.net. Every
code is listed in docs/errors.md.
Retries and timeouts
The client retries, up to max_retries times (default 2):
- 429 and 503 on any request, after the
Retry-Afterthe API sends (when it's a minute or less: a daily limit isn't worth waiting out). - Network errors and timeouts, and 500, 502 and 504, on GET, PUT and DELETE. A POST that may have reached the API isn't repeated.
# Per call
client.endorsements.list(states="CA", timeout=60, max_retries=5)
Rate limits
Your plan sets requests per minute (and per day on some plans). The latest headers are on
client.last_response.rate_limit (limit, remaining, reset, daily_limit, daily_remaining),
and on_response sees every response:
def watch(info):
if info.rate_limit.remaining is not None and info.rate_limit.remaining < 5:
print("Nearly out of requests this minute")
client = BallotData(on_response=watch)
Async
AsyncBallotData has the same resources and arguments. Methods that answer once are coroutines.
List methods can be awaited for the first page or iterated with async for for every item:
import asyncio
from ballotdata import AsyncBallotData
async def main():
async with AsyncBallotData() as client:
contest = await client.contests.get(4410)
page = await client.endorsements.list(states="CA") # the first page
async for endorsement in client.endorsements.list(states="CA"): # every item
...
async with client.stream.connect(subscription="CA labor", cursor=cursor) as stream:
async for message in stream:
if message.type == "endorsement":
await handle(message.event)
asyncio.run(main())
To stop an async stream from another task, await stream.aclose() (or cancel the task reading it).
Types
Every response and parameter has a type in ballotdata.types: response objects (Contest,
Endorsement, EndorsementEvent, …), the subscription Filter, and enums as Literals
(VoteMethod, Level, ErrorCode, …). The package ships py.typed, so mypy, Pyright and editors
such as VS Code check your code and complete field names.
from ballotdata.types import Contest, EndorsementEvent
More documentation
- Reference: every method, argument and type.
- Stream guide: syncing, cursors, reconnecting, threads, serverless.
- Postbacks guide: receiving signed changes.
- Errors: every exception and code.
- API docs and the API reference.
- Every class and method has docstrings: try
help(client.stream.connect).
License
MIT. Copyright BallotData LLC.
Metadata
Release files for ballotdata 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ballotdata-1.0.0.tar.gz | 76.7 kB | With provenance | Details for ballotdata-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ballotdata-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for ballotdata-1.0.0-py3-none-any.whl |
Total release size: 126.1 kB
Release files / ballotdata-1.0.0.tar.gz
| Download URL | ballotdata-1.0.0.tar.gz |
|---|---|
| Size | 76.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
977c16bfd4ae2fb952c5230970b8fe0d1d374314a193cbe207b6739e29e07e18
|
|
BLAKE2b-256 checksum How to use checksums |
b21a443d4976ec2fc2bacaf04ef90c755da83b666acc87f6a79d13e04280883e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logRelease files / ballotdata-1.0.0-py3-none-any.whl
| Download URL | ballotdata-1.0.0-py3-none-any.whl |
|---|---|
| Size | 49.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b9a8eb5d3500ce19ec5c5b58b5dd3c5d3ef35d4b5b8a8d86c04f750d661dcd72
|
|
BLAKE2b-256 checksum How to use checksums |
116bc9084b4de9a705dbbdc222961a23e02b6996e5a5f0a5ebafe9eda8b1ffa8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logSecurity for ballotdata 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for ballotdata-1.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.