Free, open-source REST API aggregating congressional accountability data — campaign finance, stock trades, voting records, travel disclosures, and legislation.
There is no database in the data path. The API is a thin, authenticated, rate-limited layer over CongressWatch, which rebuilds this data daily from public government records and publishes it as static JSON.
CongressWatch pipeline (8 daily cron jobs)
↓ commits JSON to git
congresswatch.vercel.app/data/*.json ← CDN, public, CORS, 1h s-maxage
↓ fetch
OSP-API (this repo) ← auth, tiers, rate limits, score breakdown
↓
consumers
Two upstream services back it:
| Concern | Service | Why |
|---|---|---|
| Congressional data | CongressWatch static JSON | Already public, CDN-cached, refreshed daily. Nothing to host. |
| API keys + accounts | Neon Postgres + Neon Auth | Durable store for api_keys; Managed BetterAuth handles email verification and OAuth. |
| Rate limiting | Upstash Redis | Sliding window, per key, per tier. |
| Resend | Delivers the API key on issuance. |
History: both halves previously ran on Supabase — one project for
api_keys+ GoTrue, another holding the congressional data. That project is gone. The data half was replaced by reading CongressWatch's JSON directly; the accounts half moved to Neon.
npm install
cp .env.local.example .env.local # fill in the values below
npx drizzle-kit push # create the api_keys table on Neon
npm run dev| Variable | Description |
|---|---|
DATABASE_URL |
Neon Postgres connection string. Use the pooled (-pooler) host. |
NEON_AUTH_BASE_URL |
Neon Auth endpoint (neonctl neon-auth status --project-id <id>) |
NEON_AUTH_COOKIE_SECRET |
Session cookie signing secret (openssl rand -base64 32, 32+ chars) |
CONGRESS_DATA_URL |
CongressWatch JSON base URL. Defaults to https://congresswatch.vercel.app/data |
UPSTASH_REDIS_REST_URL |
Upstash Redis REST URL |
UPSTASH_REDIS_REST_TOKEN |
Upstash Redis REST token |
RESEND_API_KEY |
Resend API key for the key-delivery email |
API_BASE_URL |
Public base URL for the API |
All endpoints live under /api/v1/ and return a standard JSON envelope. Field names are CongressWatch's native ones, passed through unchanged. Full reference at /docs.
Public (no key required):
GET /api/v1/members— leaderboard;chamber,party,state,min_score,search,sort,page,per_pageGET /api/v1/stats— corpus totals
Authenticated (X-API-Key header):
GET /api/v1/members/:bioguide_id— full vaultGET /api/v1/members/:bioguide_id/score— anomaly score + 6-component breakdownGET /api/v1/members/:bioguide_id/{votes,bills,trades,travel,donors,finances}GET /api/v1/trades— every trade, cross-memberGET /api/v1/bills— every bill, cross-member
Rate limits: free 1,000/hr · discounted 10,000/hr · paid 10,000/hr · admin unlimited.
The score breakdown is recomputed, not read. CongressWatch persists only the final integer score; the six weighted components are discarded. Every input survives in the JSON, so lib/scoring.ts is a port of its compute_score() (fetch_finance.py:312) that returns the components. Verified: running CongressWatch's own Python function against the same live JSON produces identical totals.
The /score response therefore returns stored (what CongressWatch saved), total (recomputed now), and drift between them. Drift is usually 0 but is legitimately non-zero — the score is only recomputed by CongressWatch's finance job, while votes/trades/travel refresh on their own schedules, so a stored score can lag its own inputs.
/v1/trades and /v1/bills build an index at runtime. CongressWatch publishes trades and bills only inside per-member detail files, so there is no single document to page over. lib/aggregate.ts fans out across all 536 detail files (~15 MB, ~1–3 s cold, ~240 ms warm) and caches the flattened result for an hour. If CongressWatch ever emits data/trades.json and data/bills.json, delete that module and read them directly.
AGPL-3.0