Early Vote & Mail Ballot Tracker — County Turnout, Registration & Results
Design notes for an Actor that returns daily county-level early-vote and mail-ballot returns, voter registration by county and party, and county results history 2000–2024 as one normalized sweep — with honest per-state coverage.
Design notes for early-vote-tracker, an Apify Actor that returns county-level US election data as one normalized sweep: daily early-vote and mail-ballot returns, voter registration statistics by county and party, and county results history from 2000 to 2024. It’s built for the AI agents doing election forecasting and political analysis — the ones that re-run a model every day during the voting window and need numbers no training corpus can contain.
What this is
Give it a set of datasets (early_vote, registration, results) and an optional scope — states, counties, a date window, result years — and it returns one normalized row per county figure. Early-vote rows carry the daily cumulative returns: ballots requested, returned, and accepted by mail, plus early in-person votes, broken out by county × party × method × day. Registration rows carry registered-voter counts by county × party, current and historical. Results rows carry the county-level presidential panel from 2000 through 2024 (plus Senate 2022), keyed by 5-digit FIPS.
The decision the Actor makes on each call is deliberately small: it does not model, project, or interpret anything. It resolves which of your requested state × dataset slices are actually live, sweeps those from a pre-normalized index, and labels everything else honestly. That narrowness is the point — the value is in the assembly and the freshness, not in an opinion about what the numbers mean.
The output is a single unified schema discriminated by a dataset field. An early-vote row and a results row are the same row shape; the fields that don’t apply are null, and a field_notes array explains any field that is structurally absent rather than merely missing. So an agent parses one schema, sums one normalized party key, and joins one county key (FIPS) across all three datasets, in every state.
It’s a sweep, not a lookup. One run returns the whole requested slice as dataset items — every covered county for the states and window you asked for — rather than answering a single point query. A single-county filter is available as a secondary mode over the same index, but the primary shape is the bulk pull a daily pipeline wants.
Why I built it this way
Freshness is the product
The strongest reason for an AI agent to call a live tool is that the answer cannot be in its training data. Daily early-vote counts are exactly that: today’s ballots-returned number for a county is post-training-cutoff by construction. No matter how good the model, it cannot know how many mail ballots Wake County, North Carolina has accepted as of this morning — it has to ask. That is a cleaner “you must call a tool” story than almost anything else I could build, and it’s why the early-vote dataset is the headline rather than the historical results.
It’s also inherently seasonal, and I designed around that instead of pretending otherwise. The demand spikes September through November of an election year, blips in off-year elections, and goes quiet in between. The backing refresh throttles itself down off-season so the fixed cost stays bounded, and the coverage labels tell a caller plainly when a seasonal source isn’t publishing yet. Building a always-hot service for a seasonal signal would have been the wrong shape.
A sweep, not a per-lookup tool
The buyer I’m building for is a forecasting or analysis pipeline that hammers a fresh source daily during the window — pull every covered county, feed the model, repeat tomorrow. That’s a whale-shaped, high-volume, repeat-sweep pattern, not a human doing occasional one-off lookups. So the product shape is a sweep: name your datasets and scope, get the whole slice back as rows. Pricing follows the same logic (see below) — friendly to big repeat pulls rather than optimized to extract maximum from a single call.
This is the inverse of a per-record lookup tool I built for property records, where each record triggers a live fetch and the cost scales per record. Here the marginal cost of serving a row is essentially nothing; the real work is keeping the index fresh. Getting that inversion right shaped both the architecture and the price.
One schema across fifty formats is the moat
Any single state’s early-vote file is a commodity — a spreadsheet, a text file, an open-data query. What’s hard, and defensible, is the unification: many states, wildly different formats, one identical output, one call. One state ships a pre-aggregated county CSV; another a tab-delimited text file compiled daily; another answers a server-side aggregation query; another posts a monthly spreadsheet. Behind the scenes each is a separate parser with its own quirks. In front, they’re one row shape.
That assembly is the same competence I’ve leaned on before — normalizing property records across 1,347 counties, or business registries across two dozen states — applied to election data. The parsers are isolated from each other on purpose: one state’s mid-September format change can’t take down the rest of the sweep. When a source’s format churns, that state degrades to a labeled row while every other state keeps flowing.
The slow, walled sources never touch your call
There’s a hard split between the refresh side and the serve side. The messy work — fetching from state portals, handling a source that only appears during an active election, archiving each raw file before parsing it, re-aggregating an individual-level source down to county totals — all happens on a schedule, behind an index. Your sweep reads pre-normalized rows from that index and nothing else. No live scrape sits in your request path, so a state portal being slow, walled, or down doesn’t slow your call; it just changes that slice’s coverage label.
I archive every source file as it comes in, too. That’s partly insurance against a state pulling historical files down, and partly because the archived daily series is the denominator for the baseline-comparison fields I want to add — same-day-versus-prior-cycle returns, computed from our own retained snapshots rather than a number a state may not still publish.
Aggregate-only, on purpose
This is a firm design line, not a coverage limitation: the Actor serves aggregate county rows only. County × party × method counts — never a voter file, never an individual ballot record, never a name or address. Some states publish their early-vote data at the individual level. Where that’s the only source, aggregation to county totals happens at ingest, before anything is stored; the individual records are never retained, never served, never redistributed. Voter files as a product are a deliberate no. A forecasting model wants the county aggregate anyway, and the aggregate is the responsible thing to serve.
Honest coverage beats an over-claimed map
I’d rather ship a smaller map that tells the truth than a bigger one that returns silent zeros. Every requested state × dataset slice that isn’t live comes back as a labeled row: not_covered (no source in scope), in_season_pending (a seasonal source that isn’t publishing yet), gateway_deferred (a bot-walled source not integrated), archive_only (historical present, not currently refreshing), or failed (the backing data couldn’t be reached for that slice). An empty result is never ambiguous — a caller can tell “no ballots yet” from “we don’t cover this” from “the source is down,” and branch accordingly.
Structural absences get the same honesty at the field level. A no-party-registration state carries a TOTAL-only registration row with a note that party breakdown is omitted by law of the source, not missing data. A results query for county-level Governor comes back labeled, because a multi-cycle county Governor panel doesn’t exist — I won’t fabricate one. Every run also writes a COVERAGE record enumerating each requested slice with its delivered row count and reason, so the live coverage is readable rather than a stale doc you have to trust.
A stable party key, with the source’s own code preserved
Consumers need to sum by party the same way in every state, so there’s a normalized party key — DEM, REP, UNA, OTH, TOTAL — that’s stable across sources. But minor-party granularity matters to some analyses, and states emit their own party codes. So each row also carries a party_detail field: the source’s own code, verbatim, whenever it publishes one, and null where the source is already pre-bucketed. You get a stable cross-state key and the underlying detail, without one destabilizing the other.
Billing that can’t surprise you
Charging is per county data row, and only after the row is pushed — push-then-charge, never on a failure. Coverage rows are free. The rate structure is graduated on purpose: live-tracking rows past a per-run threshold automatically drop to a lower volume rate, and results-history rows bill lower than live rows — so the pricing is friendlier the closer your usage gets to the daily-pipeline shape the tool is built for, and the discount is visible in the price itself rather than something to negotiate. A broad national sweep is bounded two ways: a row cap and the run’s max-charge ceiling, with every charge clamped to your remaining budget in code, so a wide pull can’t produce a runaway bill. If the budget stops a sweep, the rows you did get are billed and the rest come back explained in the coverage manifest — a soft, legible truncation rather than a failure.
How to use it
Sweep this cycle’s early-vote returns alongside the county presidential history for three states:
{
"datasets": ["early_vote", "results"],
"states": ["NC", "FL", "PA"],
"dateFrom": "2026-09-01",
"dateTo": "2026-11-03",
"resultsYears": ["2020", "2024"]
}
From the Apify Python SDK, feeding a daily model with fresh county × party × method returns:
from apify_client import ApifyClient
client = ApifyClient("YOUR_TOKEN")
run = client.actor("shelvick/early-vote-tracker").call(
run_input={
"datasets": ["early_vote"],
"states": ["NC", "FL", "PA"],
"dateFrom": "2026-09-01",
"dateTo": "2026-11-03",
}
)
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
if row["status"] == "ok":
print(row["state"], row["county"], row["party"], row["method"],
row["date"], row.get("ballots_returned"))
A single early-vote row coming out looks like this:
{
"dataset": "early_vote",
"state": "NC",
"status": "ok",
"county_fips": "37183",
"county": "Wake",
"party": "DEM",
"election_id": "2024-11-05",
"date": "2024-10-28",
"method": "mail",
"ballots_returned": 12987,
"ballots_accepted": 12750,
"source": "State Board of Elections (county absentee counts)",
"field_notes": []
}
If you’re calling from an MCP-enabled agent, the Actor is a tool on the Apify MCP server (mcp.apify.com): the input schema and per-field descriptions are advertised to the model, so an agent can construct a valid sweep — say, “daily mail ballot returns by county for these states in this window” — from the tool description alone, and pay per call via x402 USDC on Base or a Skyfire managed token.
How it compares to raw sources and static datasets
| Approach | County-level | Fresh daily in-season | One schema across states | Honest coverage labels |
|---|---|---|---|---|
| Individual state portals / files | Yes | Varies by state | No — every state a different format | No |
| Static county election datasets | Yes | No — historic only | Within one dataset | N/A |
| National topline / exit-poll feeds | No — state or national | Sometimes | No | No |
| Early Vote & Mail Ballot Tracker | Yes (FIPS) | Yes | Yes | Yes |
The static-dataset approach makes sense when all you need is the historical panel and you’re happy to re-download it once a cycle. The individual-portal approach makes sense when you only ever look at one state and are willing to own its format. This Actor makes sense when you need fresh county-level numbers across many states, day after day, behind a schema that doesn’t move under you — the case a daily forecasting pipeline actually lives in.
Pricing model
Pay-per-event, billed only on success: one charge per county data row, after it’s pushed to the dataset. The rate depends on the kind of row. Live-tracking rows (early-vote and registration) bill at a standard per-row rate, with a lower volume rate applying automatically to rows beyond a per-run threshold — a big daily sweep gets cheaper per row as it grows. Results-history rows bill at a flat rate lower than live-tracking rows, keeping a full historical panel pull inexpensive. Coverage rows — anything not live — are free, and a run stopped by your budget cap bills only the rows actually delivered, every charge clamped to your remaining budget. A single platform run-start fee applies per run.
The structure follows the demand shape. Because the marginal cost of serving a row is essentially nothing (the real cost is keeping the index fresh, a fixed monthly overhead), the graduation prices what customers actually value: the standard rate carries the freshness premium on the rows a daily tracker needs, the volume rate makes the whale-sized national sweep legibly cheaper at the margin, and the lower results rate reflects that the static historical panel isn’t the scarce thing — the daily numbers are. Current per-row rates, the volume threshold, and any subscriber discounts are on the Apify Store Pricing tab, which is authoritative.
Open questions / future work
- Same-day-vs-prior-cycle baseline fields. The index retains every day’s snapshot, which means it can carry comparison fields directly on early-vote rows — this cycle’s returns against the prior cycle’s same-day number, computed from our own archive. The schema leaves room for it; it’s the most-requested-shaped analysis and likely the next addition.
- Breadth of the fresh datasets. Early-vote and registration coverage centers on the states that publish clean, machine-readable county files, and expands as more come online. Several large states sit behind bot walls or publish only as dashboards or PDFs; those are mapped-but-deferred, and the coverage-miss signal from real usage is what will prioritize which ones get built next, rather than me guessing up front.
- Registration densification. A few states publish their county × party registration only as periodic PDFs; parsing those into the same schema is a batched follow-on rather than a launch feature.
- In-season format churn. The real operational risk is a state changing its file format mid-cycle. The parsers are isolated and every raw file is archived, so a fix can re-ingest history — but the September re-verification pass, as each state’s current-cycle files first appear, is the part that earns its keep.