Skip to content
BallotData
RecommendationsAPIPricingCoverageDocsContactAccount
Getting startedHow fresh it isCSV and JSONEmailFiles and private linksPostgreSQL dumpLive streamPostbacksBallot APIAPI reference

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.

Email

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.

sales@ballotdata.net · Contact · For endorsersTerms · Privacy · Product updatesWe supply the nonprofit voter guide endorsedby.org.© 2026 BallotData LLC. All rights reserved.