Endorsement Stream docs

Live stream: Server-Sent Events and polling

Every endorsement added, changed or withdrawn in our database becomes an event on an Amazon Kinesis stream within seconds. You read it through this API, filtered to the endorsements you want, without needing AWS credentials. Hold a connection open with Server-Sent Events, or poll with a cursor; both carry the same events.

Before you start

  • Buy an endorsements plan at ballotdata.ai with Live stream as its delivery, and choose your API account’s password at checkout. Already have a plan? Switch its delivery to the stream from your ballotdata.ai account.
  • Once it’s paid, your API account opens and we email a link to confirm your address. Confirm it, sign in and create a key with the stream:read scope in the console.
  • The stream carries only changes in the states and contests you bought, for the kinds of endorsers your delivery chose. Keys, limits and errors work as in the API docs.

Choose what you get

Either save a subscription (in the console or with POST /v1/subscriptions) and pass subscription=<id or name>, or put the filter in the query: categories, endorsers, endorser_states, states, elections, contest_kinds, levels, types (created, updated, deleted). Fields combine with AND; values within a field with OR; leave a field out to get everything.

curl -X POST https://api.ballotdata.ai/v1/subscriptions \
  -H "Authorization: Bearer ebk_…" -H "Content-Type: application/json" \
  -d '{"name": "CA labor", "filter": {"categories": ["labor"], "states": ["CA"]}}'

Or with our client libraries (npm install ballotdata, pip install ballotdata):

Save a subscription
import { BallotData } from 'ballotdata';

const client = new BallotData(); // reads BALLOTDATA_API_KEY

await client.subscriptions.create({ name: 'CA labor', filter: { categories: ['labor'], states: ['CA'] } });

Server-Sent Events

curl -N -H "Authorization: Bearer ebk_…" \
  "https://api.ballotdata.ai/v1/stream?subscription=CA%20labor"

event: ready
id: c1.eyJzIjp7…
data: {"subscription":"CA labor"}

event: endorsement
id: c1.eyJzIjp7…
data: {"id":"18342","type":"endorsement.created",…}

Each event’s id is a cursor. Reconnect with Last-Event-ID: <id> (EventSource clients do this themselves) or ?cursor= to continue where you stopped. checkpoint events move your position past changes your filter skipped; a comment line arrives every 20 seconds to keep the connection open. We end connections after an hour with a reconnect event; reconnect with the last id. New connections start at the newest change unless you pass cursor, since=<ISO time> or from=earliest. Your plan sets how many connections you can hold open.

The client libraries do all of that for you: they reconnect with the last id, skip checkpoints, wait out Retry-After, and hand you each new cursor to save.

Read the stream
import { BallotData } from 'ballotdata';

const client = new BallotData(); // reads BALLOTDATA_API_KEY

const stream = client.stream.connect(
  { subscription: 'CA labor', cursor: await loadCursor() }, // no cursor: start at the newest change
  { onCursor: (cursor) => saveCursor(cursor) },
);
for await (const message of stream) {
  if (message.type === 'endorsement') await handle(message.event);
}

Polling

GET /v1/events?subscription=CA%20labor&cursor=c1.…&wait=20
→ { "data": [ …events… ], "next_cursor": "c1.…", "has_more": false }

Store next_cursor and send it next time. wait (up to 25 seconds) holds the request until a matching change arrives. has_more means call again right away.

Poll for changes
import { BallotData } from 'ballotdata';

const client = new BallotData(); // reads BALLOTDATA_API_KEY

// Polls with wait=20, following next_cursor and has_more
for await (const { event, cursor } of client.events.follow({ subscription: 'CA labor', cursor: await loadCursor() })) {
  await handle(event);
  await saveCursor(cursor);
}

Delivery guarantees

  • At least once: in rare cases (a retry after a failure) an event can arrive twice. Use its id to drop repeats.
  • In order for each endorser. Changes to different endorsers can interleave.
  • The stream keeps changes for its retention window (a week in production). A cursor older than that gets 410 cursor_expired: catch up with /v1/endorsements, then start again with from=latest.
  • A change of an endorsement in place arrives as endorsement.updated with the new values; a removal as endorsement.deleted with the values it had. An update also says which fields changed (changed) and what they were before (previous). GET /v1/endorsements/{id}/history lists every change to an endorsement.
  • After a 410 cursor_expired, GET /v1/changes?since= with the time of the last event you handled lists every change since, deletions included.

An event

{
  "id": "18342",
  "type": "endorsement.created",
  "occurred_at": "2026-10-14T21:07:33.512Z",
  "endorsement": { "id": 90211, "rank": null, "statement": "…", "source_url": "https://…", "published_on": "2026-10-12",
                   "added_by": "data_load" },
  "changed": [],
  "previous": null,
  "endorser": { "id": 512, "slug": "sf-labor-council", "name": "San Francisco Labor Council",
                "category": { "slug": "labor", "name": "Labor" }, "state": "CA" },
  "contest": { "id": 4410, "title": "Member, Board of Supervisors, District 8", "short_title": "Supervisor, D8",
               "kind": "candidate", "vote_method": "ranked_choice", "votes_allowed": 1, "ranks_allowed": 10,
               "measure_code": null,
               "district": { "id": 77, "name": "San Francisco Supervisorial District 8", "level": "county",
                             "ocd_id": "ocd-division/country:us/state:ca/county:san_francisco/council_district:8", "state": "CA" } },
  "election": { "id": 3, "slug": "ca-2026-11-03-general", "name": "California General Election", "date": "2026-11-03",
                "state": "CA", "type": "general" },
  "choice": { "id": 20931, "label": "Jane Doe", "measure_answer": null, "candidate": { "id": 8811, "full_name": "Jane Doe" },
              "party": null, "withdrawn": false, "incumbent": true }
}

choice.incumbent is true when the candidate holds the seat now, false when they don’t, and null where we don’t know yet or for a measure answer.

Every parameter, response and error of /v1/stream, /v1/events and /v1/subscriptions is in the Stream API reference.

Live stream (SSE and polling) · BallotData API