# OppScore API: complete reference Version 1.0.0. Generated from the live gating tables, so the plan requirements below are the ones actually being enforced right now. Nonpartisan scores, roster and contact data for United States politicians. ## What this API is A read-only JSON API over the OppScore database: every United States politician we track, their nonpartisan OppScore and the five-pillar breakdown behind it, the per-question scorecard that produced the number, contested races with their full candidate fields, official and campaign contact channels, accountability findings with sources, and the published scoring instrument itself. Every route is a `GET`. There are no writes. Nothing here returns data about a person who holds an OppScore account. ## Base URL https://oppscore.org/api So the roster is `https://oppscore.org/api/v1/politicians`. `https://eufyxsceqtjigtarublg.supabase.co/functions/v1/api-gateway` is the same API on its origin host and will keep working. Prefer the base URL above in anything you write: it is the one that can be moved without breaking you. ## Authentication Send an API key on every request except `/v1/openapi.json`, `/v1/llms.txt` and `/v1/llms-full.txt`. Two header forms are accepted and they are equivalent: Authorization: Bearer opp_your_key_here X-API-Key: opp_your_key_here Keys start with `opp_` followed by 64 hex characters. Get one at https://oppscore.org/api/keys. The free tier needs no card. Keys are stored only as a SHA-256 hash, so a lost key is revoked and replaced, never recovered. Keep them server side; a key in browser or mobile code is a key anyone can read. ## Plans | plan | price | quota | rate | scores | contacts | |---|---|---|---|---|---| | free | $0 | 1,000/mo | 30/min | no | none | | developer | $49/mo | 25,000/mo | 60/min | yes | official | | campaign | $199/mo | 100,000/mo | 120/min | yes | official + campaign | | newsroom | $499/mo | 250,000/mo | 240/min | yes | official + campaign | The split is deliberate. Who holds office, who is running and when the races are decided is public record sourced from the FEC, congress-legislators and Open States, and it is free. The OppScores and the contact database are ours, and those are what the paid plans buy. The scoring instrument, `/v1/questions`, is free on every plan. A score whose criteria are not published cannot be audited. ## Conventions that apply to every route **Paging.** List and export routes take `limit` and `offset` and return `{ data, count, limit, offset, has_more }`. `count` is the size of the whole matching set, not of the page. Page until `has_more` is false. Lists default to 50 and cap at 200; exports default to and cap at 1000. **CSV.** Add `?format=csv` to any route that returns an array in `data` and you get RFC 4180 CSV with a header row and a `Content-Disposition` filename instead of JSON. `/v1/races` flattens to one row per candidate with the seat columns repeated, because a nested array in a CSV cell is useless to a spreadsheet. **The score gate.** On a plan without score access the score fields are not blanked, they are never selected. They will be absent from the response object rather than present and null. Sorting by score is refused with 403 on those plans too, because a ranked list of names gives away the ranking without the numbers. **Incremental sync.** `/v1/politicians` and `/v1/export/politicians` accept `updated_since`, and `/v1/export/scores` accepts `scored_since`. Both take an ISO 8601 timestamp. Store the newest `updated_at` (or `scored_at`) you have seen and pass it back to fetch only what changed. A full nightly scorecard export is 107 paged calls against a 25,000 call month; the delta is usually one. **Response headers.** Every response carries `X-Request-Id`, `X-Plan`, `X-Rate-Limit-Limit` and `X-Rate-Limit-Remaining`. Quote the request id if you report a problem. On the free plan `X-Attribution-Required` names the link back to oppscore.org that the free terms ask for. **States.** Anywhere a state is accepted, a two letter USPS code or a full state name works, in any case: `TX`, `tx`, `Texas` and `texas` are the same request. An unrecognised state is a 404 that names the problem, never an empty list. ## Endpoints ### Politicians The roster and individual profiles. #### `GET /v1/politicians` List and filter the roster Minimum plan: **free** | parameter | in | type | notes | |---|---|---|---| | `state` | query | string | Two letter USPS code or full state name, any case. TX, tx, Texas and texas all work. | | `party` | query | string | Exact party name as stored, for example Republican. | | `position` | query | string | | | `is_running` | query | boolean | | | `is_currently_serving` | query | boolean | | | `search` | query | string | Case-insensitive match on name and full name. | | `sort` | query | string (name_asc, score_desc, score_asc) | score_desc and score_asc require the developer plan. | | `updated_since` | query | string, date-time | ISO 8601 timestamp. Returns only rows whose updated_at is at or after it. Checkpoint the newest updated_at you have seen and pass it back to sync incrementally instead of refetching everything. | | `limit` | query | integer | | | `offset` | query | integer | | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/politicians" ``` #### `GET /v1/politicians/{slug}` One politician in full Minimum plan: **free** | parameter | in | type | notes | |---|---|---|---| | `slug` | path, required | string | The politician's URL slug, for example al-green. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/politicians/al-green" ``` ### Scores OppScores, the Five Points breakdown and score history. #### `GET /v1/politicians/{slug}/scorecard` Why this politician scores what they score Minimum plan: **developer** One row per criterion in the active instrument, whether or not this politician has been assessed on it. A criterion with `scored: false` has not been assessed yet and must be treated as absent, never as a zero. Read `is_inverted` from here or from `/v1/questions`: on an inverted criterion a low `raw_score` is the favourable answer. | parameter | in | type | notes | |---|---|---|---| | `slug` | path, required | string | The politician's URL slug, for example al-green. | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/politicians/al-green/scorecard" ``` #### `GET /v1/scores/{slug}/history` Every OppScore snapshot for one politician Minimum plan: **developer** | parameter | in | type | notes | |---|---|---|---| | `slug` | path, required | string | The politician's URL slug, for example al-green. | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/scores/al-green/history" ``` ### Contacts Official and campaign contact channels. #### `GET /v1/politicians/{slug}/contacts` Contact channels for one politician Minimum plan: **developer** | parameter | in | type | notes | |---|---|---|---| | `slug` | path, required | string | The politician's URL slug, for example al-green. | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/politicians/al-green/contacts" ``` ### Elections Races and who is on the ballot. #### `GET /v1/states` States, with how many politicians and candidates each holds Minimum plan: **free** ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/states" ``` #### `GET /v1/races` Contested seats and their candidate fields Minimum plan: **free** A race is two or more candidates declared for the same seat in the same cycle. Each race carries its full field inline, so no follow-up call is needed. The seat is the one a candidate FILED for, which is not always the one they hold: someone who wins a gubernatorial primary while sitting in the House belongs to the Governor race, not their old House race. Candidates beaten in a primary are excluded from the general election field. `best_opp_score` and `worst_opp_score` on the race, and `opp_score` on each candidate, require the developer plan. On Free the field is returned in a neutral order that encodes no ranking. | parameter | in | type | notes | |---|---|---|---| | `state` | query | string | Two letter USPS code or full state name, any case. | | `office` | query | string | Office code, for example us_house, us_senate, governor, state_senate. | | `office_level` | query | string (federal, state, local) | | | `year` | query | integer | Election cycle, for example 2026. | | `contested_only` | query | boolean | true returns only fields of three or more. | | `limit` | query | integer | | | `offset` | query | integer | | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/races" ``` #### `GET /v1/elections/{state}` Races in a state Minimum plan: **free** | parameter | in | type | notes | |---|---|---|---| | `state` | path, required | string | Two letter USPS code or full state name. | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/elections/TX" ``` ### Methodology The scoring instrument itself: every criterion, its pillar and its weight. #### `GET /v1/questions` The scoring instrument Minimum plan: **free** Every active criterion, the pillar it rolls up into, how heavily it counts and whether it is inverted. Free on every plan on purpose: a score whose criteria are not published is a score nobody can audit. The grading rules, the model prompts and the internal scoring logic are not part of this API. | parameter | in | type | notes | |---|---|---|---| | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/questions" ``` ### Accountability Dirt Alerts: verified findings with sources. #### `GET /v1/politicians/{slug}/dirt-alerts` Accountability findings for one politician Minimum plan: **developer** | parameter | in | type | notes | |---|---|---|---| | `slug` | path, required | string | The politician's URL slug, for example al-green. | | `limit` | query | integer | | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/politicians/al-green/dirt-alerts" ``` #### `GET /v1/dirt-alerts` Recent accountability findings Minimum plan: **developer** | parameter | in | type | notes | |---|---|---|---| | `severity` | query | string | | | `category` | query | string | | | `since` | query | string, date-time | ISO timestamp. Only findings published at or after this. Use it to poll for what is new. | | `limit` | query | integer | | | `offset` | query | integer | | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/dirt-alerts" ``` ### Bulk Paginated exports, JSON or CSV. #### `GET /v1/export/politicians` Bulk roster export Minimum plan: **developer** | parameter | in | type | notes | |---|---|---|---| | `updated_since` | query | string, date-time | ISO 8601 timestamp. Returns only rows whose updated_at is at or after it. Checkpoint the newest updated_at you have seen and pass it back to sync incrementally instead of refetching everything. | | `limit` | query | integer | | | `offset` | query | integer | | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/export/politicians" ``` #### `GET /v1/export/scores` Bulk scorecard export, one row per criterion Minimum plan: **developer** | parameter | in | type | notes | |---|---|---|---| | `scored_since` | query | string, date-time | ISO 8601 timestamp. Returns only rows whose scored_at is at or after it. Checkpoint the newest scored_at you have seen and pass it back to sync incrementally instead of refetching everything. | | `limit` | query | integer | | | `offset` | query | integer | | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/export/scores" ``` #### `GET /v1/export/contacts` Bulk contact export Minimum plan: **campaign** | parameter | in | type | notes | |---|---|---|---| | `channel` | query | string (official, campaign) | official or campaign. Asking for a channel your plan does not carry is a 403, not an empty list. | | `kind` | query | string (email, phone, website, social, address, contact_form, fax) | | | `state` | query | string | | | `limit` | query | integer | | | `offset` | query | integer | | | `format` | query | string (json, csv) | Set to csv to receive the rows as RFC 4180 CSV instead of JSON. | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/export/contacts" ``` ### Meta Key status and machine readable description. #### `GET /v1/me` Your key, plan and remaining quota Minimum plan: **free** Free on every plan, and it does not consume quota, so it is safe to call from a health check. Keeps answering when the quota is exhausted. ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/me" ``` #### `GET /v1/openapi.json` This document **No API key required.** Fetch this before you have one. One of three routes that answer without a key, alongside /v1/llms.txt and /v1/llms-full.txt, so client generators and API explorers work before you sign up. ```bash curl "https://oppscore.org/api/v1/openapi.json" ``` #### `GET /v1/llms.txt` Short documentation index for AI agents **No API key required.** Fetch this before you have one. Plain text, no key required. The llms.txt convention: a small file an agent fetches first to learn what this API is, where its documentation lives and how to authenticate. Points at the full reference rather than trying to be it. ```bash curl "https://oppscore.org/api/v1/llms.txt" ``` #### `GET /v1/llms-full.txt` The entire API reference in one plain text file **No API key required.** Fetch this before you have one. Plain text, no key required. Every route, parameter, response shape, error code and convention, plus the reasoning an implementer needs, in a single fetch. Written for a coding agent: an agent that has to make six requests to assemble the documentation is an agent that gives up and guesses on the third. Rendered from the same object that produces `/v1/openapi.json`, so the two cannot disagree. ```bash curl "https://oppscore.org/api/v1/llms-full.txt" ``` #### `GET /v1/polls` Recent community polls Minimum plan: **free** | parameter | in | type | notes | |---|---|---|---| | `limit` | query | integer | | ```bash curl -H "Authorization: Bearer opp_your_key" \ "https://oppscore.org/api/v1/polls" ``` ## Response shapes ### Contact | field | type | notes | |---|---|---| | `channel` | string | official is government published. campaign is the committee or campaign organisation. One of: official, campaign. | | `kind` | string | One of: email, phone, website, social, address, contact_form, fax. | | `platform` | string or null | For socials: the network. | | `label` | string or null | | | `value` | string | | | `source` | string | Where this came from: congress_legislators, openstates_bulk, fec, campaign_crawler or manual. | | `source_url` | string or null | | | `confidence` | number | 0 to 1. A committee email whose domain does not match the campaign website is stored at 0.45. | | `is_primary` | boolean | | | `is_verified` | boolean | True only where a human or the politician confirmed it. | | `last_seen_at` | string or null | | ### ContactsResponse | field | type | notes | |---|---|---| | `data` | array | | | `count` | integer | | | `channels_included` | array | | | `notice` | string | | ### Error | field | type | notes | |---|---|---| | `error` | string | | | `plan` | string | | | `required_plan` | string | | | `upgrade_url` | string | | | `request_id` | string | Quote this when you contact support. | ### ListResponse | field | type | notes | |---|---|---| | `data` | array | | | `count` | integer or null | The size of the whole matching set, not of this page. | | `limit` | integer | | | `offset` | integer | | | `has_more` | boolean | | ### MeResponse | field | type | notes | |---|---|---| | `key` | object | | | `plan` | object | | | `subscription` | object | | | `usage` | object | | ### Politician | field | type | notes | |---|---|---| | `id` | string | | | `name` | string | | | `slug` | string | | | `full_name` | string or null | | | `party` | string or null | | | `state` | string or null | Full state name, for example Texas. | | `position` | string or null | | | `seat` | string or null | | | `district` | string or null | | | `photo_url` | string or null | | | `website_url` | string or null | | | `status` | string or null | | | `is_currently_serving` | boolean | | | `is_running` | boolean | | | `last_scored_at` | string or null | | | `updated_at` | string or null | Checkpoint this and pass the newest value back as updated_since to sync incrementally. | | `opp_score` | number or null | Developer plan and above. Omitted entirely on Free. | | `score_point_1` | number or null | Personal pillar. Developer plan and above. | | `score_point_2` | number or null | Economic pillar. Developer plan and above. | | `score_point_3` | number or null | Social pillar. Developer plan and above. | | `score_point_4` | number or null | Educational pillar. Developer plan and above. | | `score_point_5` | number or null | National pillar. Developer plan and above. | ### PoliticianResponse | field | type | notes | |---|---|---| | `data` | object | | ### Question | field | type | notes | |---|---|---| | `question_code` | string | Stable identifier. This is the join key against every scorecard row. | | `pillar_code` | string | One of: PERSONAL, ECONOMIC, SOCIAL, EDUCATIONAL, NATIONAL. | | `sub_point_number` | integer or null | | | `part_number` | integer or null | | | `display_order` | integer | | | `weight` | number | How heavily this criterion counts toward the pillar. | | `is_inverted` | boolean | When true, a LOW raw_score is the favourable answer. Ignoring this inverts the conclusion. | | `is_national_only` | boolean | Applies to federal offices only. | | `question_text` | string or null | The criterion as it is put to a politician. | | `member_question_text` | string or null | The same criterion phrased for a member taking the OppScore Challenge. | | `scoresheet_version` | integer or null | | ### QuestionsResponse | field | type | notes | |---|---|---| | `data` | array | | | `pillars` | array | The five pillars, with how many criteria each carries. | | `count` | integer | | | `notice` | string | | ### Race | field | type | notes | |---|---|---| | `race_key` | string | state\|office_code\|district\|year. Stable across calls. | | `state` | string | Two letter USPS code. | | `office_code` | string | | | `office_display` | string | | | `office_level` | string | One of: federal, state, local, unknown. | | `district` | string | Empty string means statewide or at large. | | `election_year` | integer | | | `candidate_count` | integer | | | `scored_candidate_count` | integer | | | `primary_decided` | boolean | | | `concluded` | boolean | True once the cycle's November has passed. | | `versus_path` | string | | | `versus_url` | string | The public OppScore Versus page for this seat. | | `best_opp_score` | number or null | Developer plan and above. | | `worst_opp_score` | number or null | Developer plan and above. | | `candidates` | array | | ### RaceCandidate | field | type | notes | |---|---|---| | `id` | string | | | `slug` | string | | | `name` | string | | | `party` | string or null | | | `photo_url` | string or null | | | `role` | string or null | | | `status_display` | string or null | | | `is_running` | boolean or null | | | `is_currently_serving` | boolean or null | | | `primary_result` | string or null | Outcome of this cycle's PRIMARY, which says nothing about the general. Primary losers are not returned at all. One of: won, advanced, unopposed, uncalled. | | `primary_result_date` | string or null | | | `filed_elsewhere` | boolean | True when this person currently holds a different seat from the one they are contesting. | | `opp_score` | number or null | Developer plan and above. Omitted entirely on Free. | ### RacesResponse | field | type | notes | |---|---|---| | `data` | array | | | `count` | integer or null | | | `limit` | integer | | | `offset` | integer | | | `has_more` | boolean | | | `notice` | string | | ### ScorecardResponse | field | type | notes | |---|---|---| | `data` | array | | | `politician` | object | | | `count` | integer | Criteria in the active instrument. | | `scored_count` | integer | | | `unscored_count` | integer | | | `coverage` | number | scored_count divided by count, to four places. | | `notice` | string | | ### ScorecardRow | field | type | notes | |---|---|---| | `question_code` | string | | | `pillar_code` | string | | | `display_order` | integer | | | `weight` | number | | | `is_inverted` | boolean | | | `question_text` | string or null | | | `raw_score` | number or null | | | `scored_at` | string or null | | | `scored` | boolean | False means this criterion has not been assessed for this politician. Treat it as absent, never as a zero. | ## Errors Every error is JSON carrying `error` and `request_id`. Where there is something useful to add, such as which plan a route needs, it is in the body too. | status | meaning | what to do | |---|---|---| | 400 | A parameter could not be read, usually a malformed timestamp. The body quotes the value back. | Fix the value. Do not retry unchanged. | | 401 | No key, a key not starting `opp_`, or a key we do not recognise. | Check the header. Do not retry unchanged. | | 403 | The route, sort order or contact channel needs a higher plan, or the key was revoked or expired. The body names the plan required. | Upgrade, or stop asking for the gated thing. Do not retry unchanged. | | 404 | Unknown route, unknown slug, a politician who is not live, or a state that did not resolve. | Do not retry unchanged. | | 429 | Per-minute rate limit or monthly quota. `Retry-After` says how long. | Sleep for `Retry-After` seconds, then retry. | | 503 | We could not reach the database to decide, so we declined rather than guess. | Retry after 5 seconds. Safe to retry. | A 401, 403 or 404 will not become a 200 on retry. Retrying those is how an integration burns its quota against a mistake. ## Notes for an agent implementing against this 1. Call `/v1/me` first. It is free, costs no quota, and tells you the plan, the remaining quota and the exact entitlements, so you can branch on what is actually available instead of discovering it through 403s. 2. Read `/v1/questions` before you interpret any score. `is_inverted` marks the criteria where a LOW `raw_score` is the favourable answer. Ignoring it does not blur a conclusion, it reverses one. 3. On `/v1/politicians/{slug}/scorecard`, a row with `scored: false` has not been assessed for that politician. Treat it as absent. Averaging it in as a zero silently turns a partly scored politician into a badly scored one. 4. A race from `/v1/races` is a contest: two or more candidates declared for the same seat and cycle. The seat is the one a candidate FILED for, which is not always the one they hold. Candidates beaten in a primary are not in the field. 5. `primary_result` describes this cycle's PRIMARY and says nothing about who holds the seat. Do not render it as a general election outcome. 6. Sync incrementally. `updated_since` and `scored_since` exist so you do not have to refetch a set that changed by four rows. 7. If you are on the free plan, the terms ask for a visible link back to oppscore.org. `X-Attribution-Required` carries the URL on every response. ## Data boundaries, stated plainly Campaign street addresses are excluded on every plan, including the highest. They originate in FEC committee filings and are frequently a candidate's home address. The exclusion is in the database view, not in application code, so no endpoint can return one. Official capitol and district office addresses are included and are a different thing: published by the government so constituents can write in. The grading machinery behind the scores, the rules and prompts that tell a model how to assess a criterion, is not part of this API. The criteria themselves are, at `/v1/questions`, so the scores can be audited. This API has no route that reads a table about a person who holds an OppScore account. ## Support https://oppscore.org/api for the human documentation, and the OpenAPI document at https://oppscore.org/api/v1/openapi.json for a machine contract you can generate a client from.