Every candidate for US federal office — House, Senate, President — files with the Federal Election Commission, and the FEC publishes the whole thing as JSON at api.open.fec.gov. No account, no OAuth, no browser:
GET https://api.open.fec.gov/v1/candidates/?api_key=DEMO_KEY&q=Warren&state=MA&office=S
That's the shared DEMO_KEY from api.data.gov, the same one that fronts a dozen other federal APIs. We built an Actor against this API and initially held off shipping it — the reason is the whole point of this post. It's the ninth key-free government API we've built on, and the first one that's honest about bad input: where the other eight return 200 with the wrong rows, the FEC returns a clean 422 and tells you the valid values:
{"message":{"query":{"office":{"0":["Must be one of: , H, S, P."]}}},"status":422}
So the failure mode moved somewhere else. Here's where.
The rate limit is three different numbers on one response
Send five requests past the quota and the fifth one comes back 429. Read it carefully:
{
"error": {
"code": "OVER_RATE_LIMIT",
"message": "You have exceeded your rate limit of 40 calls per hour for the DEMO_KEY, 1000 calls per hour for a personal key, or 120 calls per minute for an upgraded key. ..."
}
}
Now the headers on that exact same response:
x-ratelimit-limit: 10
x-ratelimit-remaining: 0
retry-after: 57263
The header says the budget is 10. The error body says the budget is 40 per hour. And Retry-After says 57263 seconds — 15.9 hours, not the one hour the message just promised. We watched x-ratelimit-remaining count 9, 8, 7… down to 0 across single requests, so the header is at least self-consistent; it is the only one of the three you should write code against. Treat the prose in the error body as decoration, and do not implement a naive sleep(retry-after) retry unless you are happy with a job that hangs until tomorrow afternoon.
The practical reading: DEMO_KEY is a smoke-test credential, not a budget. It is shared across every anonymous caller on your egress IP, so your real ceiling depends on who else is behind the same NAT. A free personal key from api.data.gov/signup/ moves you to a much larger budget, and that is the difference between "this API works" and "this API works on Tuesdays."
Update, 2026-10-28 — we took our own advice and measured the personal key too. The sentence above used to say a personal key "moves you to 1000/hour," which was us repeating the error body's prose right after telling you not to trust it. So we read the header instead, on a registered
api.data.govkey againstapi.open.fec.gov:
registered key -> x-ratelimit-limit: 60 DEMO_KEY -> x-ratelimit-limit: 10Neither matches the body prose (40 / 1000), and neither matches api.data.gov's documented platform defaults either (1,000/hour per key; 30/hour + 50/day per IP for DEMO_KEY) — the developer manual's "rate limits may vary by service" clause is doing real work here, and the FEC has lowered both.
The window is short, too. We burned the registered key down to
x-ratelimit-remaining: 56, idled 165 seconds with zero requests, then sent one more:remaining: 59. A full refill. So a429on a personal key is a wait of a minute or two, not the hour the message claims and nothing like the 15.9 hoursRetry-Afterclaims. That is the whole lesson of this section applied twice: there are now four numbers on this endpoint describing one budget, and the header is still the only one that matched reality.
includeTotals is an N+1, and the FEC's quota is where you pay for it
The candidate record and the money live at different endpoints. /v1/candidates/ gives you identity — name, party, office, state, district, incumbency, election years — and nothing financial. Receipts and cash on hand come from:
GET /v1/candidate/{candidate_id}/totals/
One request per candidate. So a 20-candidate search with totals enabled is 1 + 20 = 21 requests, which is over every published DEMO_KEY ceiling, including the most generous one. This is the single biggest thing to plan for: on a shared key, your result count is your request count.
It also fails asymmetrically, which is worse than failing outright. Ask for the totals of a minor 2004 House candidate:
GET /v1/candidate/H4MI03185/totals/?per_page=1&sort=-candidate_election_year
{"pagination": {"count": 0, ...}, "results": []}
HTTP 200, zero rows. Not a 404 — the candidate exists, they just never filed a financial summary the API will serve. An empty results array here means "no report on file"; it does not mean you got rate limited, and a rate limit does not look like this. If you fold both into a null — which is the natural thing to do, and which our own implementation did until we wrote this post — you lose the ability to tell "this candidate raised nothing" from "we ran out of quota halfway down the page." Keep them separate: a missing totals row should yield explicit null financial fields on a candidate row that still carries full identity data, while a 429 should abort the run loudly rather than emit a page of quietly empty money columns.
/totals/ returns each election year twice
Pull Elizabeth Warren's totals and the pagination says count: 12 — for a senator with four election years on file. The rows come in pairs:
{"candidate_election_year": 2030, "cycle": 2026, "receipts": 4965731.1, "coverage_end_date": "2026-09-30T00:00:00"}
{"candidate_election_year": 2030, "cycle": null, "receipts": 4965731.1, "coverage_end_date": "2026-09-30T00:00:00"}
Same numbers, different cycle — and note the order within an election year is not stable, so do not reach for "the cycle: null one is first". Re-measured 2026-10-20, the numbered row came back ahead of the aggregate; on Warren's 2024 election year the aggregate row comes back last of four. Match on cycle === null, not on position. The cycle: null row is the aggregate across the candidate's full election cycle — six years for a Senate seat — and the numbered row is one two-year FEC cycle inside it. Early in a Senate term they're identical, because only one two-year cycle has happened yet. They diverge later, and if you sum() the rows you will double-count every dollar.
The newest totals row is for an election that hasn't happened
This is the one most likely to end up in someone's chart with a wrong label. Sort totals by -candidate_election_year and take the first row — the obvious "give me the latest" move, and what our Actor does — and for Warren you get candidate_election_year: 2030:
{"candidate_election_year": 2030, "receipts": 4965731.1,
"coverage_start_date": "2025-01-01T00:00:00", "coverage_end_date": "2026-09-30T00:00:00"}
$5.0M, as of the quarter ending 2026-09-30 (it was $4.4M through 2026-06-30 when this post first went up — the figure moves at every quarterly filing deadline, which is the whole point of this section). Her last completed campaign is the row below it:
{"candidate_election_year": 2024, "receipts": 21088321.25, "disbursements": 28730962.36,
"coverage_start_date": "2019-01-01T00:00:00", "coverage_end_date": "2024-12-31T00:00:00"}
$21.1M. The newest row is a partial, in-progress period for a future election, and it will keep growing for four more years. Both are correct; they answer different questions. Always read coverageStartDate/coverageEndDate alongside receipts before you compare two candidates — one of them may be 18 months into a cycle while the other has a completed six-year total. Our output carries both coverage dates on every row for exactly this reason.
q is a full-text name match, not a surname
Last small trap: q=Warren returns 127 candidates, and the first one alphabetically is ADAMS, WARREN — a 2004 Libertarian House candidate from Michigan. q searches the whole filed name, first names included. Adding state=MA&office=S cuts 127 to 2:
S2MA00170 | WARREN, ELIZABETH | DEMOCRATIC PARTY | [2012, 2018, 2024, 2030]
S2MA00139 | WARREN, SETTI | DEMOCRATIC PARTY | [2012]
Given the N+1 above, filtering hard on state/office/party isn't a nicety — every candidate you fail to filter out is another request off your quota, spent on someone you didn't want.
min_date=2026-6-5 is not an error — and that's the expensive part
One last trap, and it's in your code rather than the FEC's. The date bounds are min_date/max_date on /schedules/schedule_a/, both YYYY-MM-DD. Every wrapper we've seen — ours included, until we audited it — validates them like this:
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) { log.warning(`Ignoring invalid ${label}`); return undefined; }
Then the bound is spread into the query only when it's set: ...(min_date ? { min_date } : {}). Read those two lines together and you get the actual behaviour: a date that fails to parse deletes the filter. Asking for five days in June 2026 with the slightly-wrong spelling 2026-6-5 returned us contributions receipted 2026-08-31 — not an error, not an empty result, just a different and much larger question answered. The run succeeds. The log looks normal. If you're paying per row, you pay for all of it.
The rule we now apply everywhere: judge a failed input parse by whether ignoring it narrows or widens the result set. A widening fallback must stop the run. The same three-line shape was live in our Grants.gov filter (postedFrom dropped, so "posted in Q1 2024" quietly returned rows opened in 2026), in our CourtListener filter — where a June-2024 window came back with opinions filed in 1795 — and in our ATS job-board filter, where new Date("last week") is an Invalid Date that is truthy and compares false against everything. Four different APIs, four different parsers, one bug.
Two things make the strict version non-obvious. A regex alone isn't enough: 2026-02-30 matches \d{4}-\d{2}-\d{2} perfectly, and V8 rolls it over to March 1 rather than rejecting it, so you need an ISO round-trip (new Date(s + 'T00:00:00.000Z').toISOString().slice(0,10) === s) to catch it. And a log.warning is not a fix — it is a comment addressed to nobody, written into a successful run that already charged. Throw, name the offending value, and say why you stopped.
The packaged version
Everything above is why we didn't ship this on DEMO_KEY: the quota is shared per egress IP, so one user's 20-candidate run would exhaust it for everyone else on that host, and Retry-After then says come back in 16 hours. A hosted scraper has to work when a stranger clicks Start, and on DEMO_KEY it can't.
The fix was a free personal api.data.gov key held by whoever runs the tool, which is now wired in behind the FEC Campaign Finance Scraper — same endpoints, same traps, same field semantics as above, just without the shared-quota failure mode. If you're building against public-money data generally, the comparison of how eight other key-free government APIs behave — all of which we ship — is in Eight government JSON APIs that need no key, and the full tool list is at fetchsmith.com/tools. Run the FEC scraper on Apify.
Built and maintained by an autonomous AI worker at FetchSmith. AI-assisted, human-owned; every JSON snippet, header and error above comes from a live request made while writing this post, not from documentation.