Documentation
Every subscription delivers the same data: each recommendation an endorser has published for a contest or measure on an upcoming ballot, tied to the choice on the printed ballot, with its source. You choose how it arrives, and you can change that any time from your account.
Try the formats with the free samples: CSV and JSON.
How fresh it is
New and changed recommendations generally publish within the hour of an endorser announcing them. Emails (each change), postbacks and the live stream carry them as they publish; hourly emails summarize the hour; and a complete file is built every day at 13:00 UTC. Withdrawn recommendations are sent as changes and disappear from the next day’s file.
CSV and JSON
CSV is UTF-8 with a byte-order mark (so Excel reads accents correctly), one row per recommendation, and the column names on the first line. Every row repeats its election, contest, district, choice and endorser, so it needs no joins. Columns:
recommendation_id, election_id, election_name, election_date, election_type, state, contest_id, contest_title, contest_type, measure_code, district_id, district_name, district_level, district_type, ocd_id, census_geoid, vote_method, seats, ranks_allowed, party_ballot, choice_id, choice, candidate_id, candidate_name, ballot_designation, choice_party, measure_position, withdrawn, incumbent, endorser_id, endorser_slug, endorser_name, endorser_type, endorser_state, endorser_party, endorser_website, rank, source_url, published_on, retrieved_at
JSON (ballotdata.recommendations/1) keeps the ballot’s structure: a head with coverage, counts, endorser types and endorsers, then elections → contests → choices → recommendations, in ballot order. Each contest carries endorser_count; each recommendation names its endorser by id, name and type.
Change emails attach a CSV or JSON of just the changes. The CSV columns are: change_id, change_type, occurred_at, recommendation_id, election_id, election_name, election_date, state, contest_id, contest_title, contest_type, measure_code, district_name, district_level, vote_method, seats, ranks_allowed, choice_id, choice, candidate_id, choice_party, measure_position, withdrawn, endorser_id, endorser_slug, endorser_name, endorser_type, endorser_state, rank, source_url, published_on.
Choose each change as it happens, an hourly summary (sent only when something changed), or a daily email with the whole file attached. When a daily file is too big to attach, the email links to it instead; the link works for seven days without signing in.
Files and private links
File subscribers get a private link that always answers today’s file, for scripts and scheduled jobs. Create or replace it from your account (replacing it stops the old one working).
curl -fL -o recommendations.csv https://ballotdata.net/files/bdf_your_private_token
Signed in, you can also download today’s file in any format from your account.
PostgreSQL dump
A gzipped SQL file that replaces a ballotdata schema with tables election, district, contest, choice, endorser_type, endorser and recommendation, plus a recommendation_flat view with the CSV’s columns. It runs in one transaction, so readers never see a half-loaded copy.
curl -fL https://ballotdata.net/files/bdf_your_private_token | gunzip | psql "$DATABASE_URL"
Live stream
Stream subscribers get an API key for the Ballot API’s event stream, limited to their coverage. Connect with Server-Sent Events; each endorsement event carries a change, and its id lets you resume after a disconnect without missing anything.
curl -N -H "Authorization: Bearer $BALLOTDATA_KEY" "https://api.endorsedby.org/v1/stream?from=latest"
Prefer polling? /v1/events?wait=25 long-polls the same changes. See the reference.
Postbacks
We POST each batch of changes to your HTTPS endpoint as JSON, as they publish. Answer with any 2xx status; anything else (or no answer within 15 seconds) is retried with growing gaps of up to an hour, and the error is shown in your account. We don’t follow redirects.
POST https://your-server.example/ballotdata
Content-Type: application/json
BallotData-Signature: t=1760620800,v1=5f2b…
{
"delivery_id": 42,
"sent_at": "2026-10-16T14:02:11.000Z",
"changes": [
{ "id": "88123", "type": "endorsement.created", "occurred_at": "…",
"endorsement": { "id": 880412, "rank": null, "source_url": "https://…", "published_on": "2026-10-15" },
"endorser": { "id": 77, "name": "…", "category": { "slug": "advocacy", "name": "Advocacy" }, "state": "CA" },
"contest": { "id": 9031, "title": "Proposition 5", "kind": "measure", "district": { … } },
"election": { "id": 12, "name": "November 3, 2026 General", "date": "2026-11-03", "state": "CA" },
"choice": { "id": 51001, "label": "Yes", "measure_answer": "yes" } }
]
}Check every request’s BallotData-Signature header with the secret in your account: it holds a timestamp t and v1, the hex HMAC-SHA256 of t, a dot, and the raw body.
import { createHmac, timingSafeEqual } from 'node:crypto';
// header: the BallotData-Signature request header, e.g. "t=1760620800,v1=5f2b…"
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
}Ballot API
The API answers JSON for elections, contests, choices, endorsers and recommendations in the states you subscribe to, with cursor paging. Send your key as Authorization: Bearer. The full list of endpoints is in the API reference, built from the API’s OpenAPI description.
curl -H "Authorization: Bearer $BALLOTDATA_KEY" "https://api.endorsedby.org/v1/recommendations?state=CA"
Need something else?
A different format, SFTP, S3, a spreadsheet you share with us: just ask.