API v2 and scoped keys
https://www.chicagopolitics.org/api/v2 serves the same public data as v1 and, for dashboard members, your own follows and your team's follows, notes, positions and dashboards, which you can also change. Every operation names the scopes it needs (x-scopes) in the v2 OpenAPI document. v1 is unchanged.
Scoped keys
Make keys in the dashboard under Settings → API keys: a name, Personal or one of your teams, the scopes, an optional expiry and optional allowed IPs (addresses or CIDR ranges, IPv4 or IPv6). The key is shown once; you can rename it, change its expiry and its allowed IPs later. Only team owners and admins make team keys. A key never does more than the account that made it can do right now: if that person loses a role or the team loses a feature, the key loses it on its next request, and if they leave the team, its keys are revoked. Team owners and admins can see, edit and revoke every key on their team. Keys made before scopes existed keep working with public.read and public.ai.
| Scope | Key | Gives |
|---|---|---|
public.read | any | Public data: matters, votes, meetings with insights, alderpersons, money and lobbying (and all of v1) |
public.ai | any | AI answers (v1 POST /answers, GraphQL answer, MCP, A2A) |
me.follows.read | personal | GET /me/follows: what you follow |
me.follows.write | personal | POST /me/follows, DELETE /me/follows/{id} |
team.follows.read | team | GET /team/follows |
team.follows.write | team (owners, admins) | POST /team/follows, DELETE /team/follows/{id} |
team.notes.read | team | GET /team/notes: notes shared with the team |
team.notes.write | team | POST /team/notes, PATCH / DELETE /team/notes/{id} (your own notes) |
team.positions.read | team | GET /team/positions; GET /team/legislation/{record}/stance and /targets |
team.positions.write | team (owners, admins) | PUT / POST /team/positions, DELETE /team/positions/{id}; PUT / DELETE /team/legislation/{record}/stance; POST /team/legislation/{record}/targets, DELETE /team/legislation/{record}/targets/{personId} |
team.dashboards.read | team | GET /team/dashboards: Analytics dashboards shared with the team |
team.dashboards.write | team | POST /team/dashboards, PATCH / DELETE /team/dashboards/{id} |
people.read | team | GET /people, /people/{id}, /meetings/{id}/testimony: officials, City staff and lobbyists |
people.private.read | team (offices) | Adds residents and organization representatives to the people routes |
Requests
- Send
Authorization: Bearer <key>. Signed-in dashboard sessions work too. GET /meneeds no scope: your account, team, scopes, limits and usage today.- Lists take
limit(1-200) andcursor(frommeta.nextCursor). - Errors are
{ "error": { "code", "message" } }, for examplescope_missing,team_only,daily_limit. - Team, personal and people data are sent with
Cache-Control: private, no-store. - Team keys share the team's daily limits; personal keys use your account's (see rate limits).
curl "https://www.chicagopolitics.org/api/v2/team/follows" \
-H "Authorization: Bearer $CHICAGO_POLITICS_KEY"
Writing
- A write needs its
.writescope, the team feature, and a role that could make the same change in the dashboard (team follows and positions: owners and admins). It only ever touches your own team; an id from another team is404. - Open dashboards see API writes at once (team follows, shared notes, positions and stances on bills, target lists, dashboards).
- Every write (
POST,PUT,PATCH,DELETE) takes anIdempotency-Keyheader (1-255 characters). Retrying with the same key replays the first answer for 24 hours (Idempotent-Replayed: true); the same key with a different request is422 idempotency_key_reused. - Dashboards:
PATCHwithbaseVersionandlayoutanswers409 conflictwhen someone saved the layout since; read it again and reapply.
curl -X POST "https://www.chicagopolitics.org/api/v2/team/follows" \
-H "Authorization: Bearer $CHICAGO_POLITICS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d1c6a52-sync-1" \
-d '{"type": "legislation", "refId": "O2026-0001234", "label": "Rent stabilization ordinance"}'
curl -X PUT "https://www.chicagopolitics.org/api/v2/team/positions" \
-H "Authorization: Bearer $CHICAGO_POLITICS_KEY" \
-H "Content-Type: application/json" \
-d '{"positions": [{"label": "Support the rent ordinance", "stance": "supports", "recordId": "O2026-0001234"}]}'
Your team's stance on a bill
A team has one stance per bill: supports or opposes. It is the stance the dashboard's Legislation page shows ("You support"), and the same row as that bill's entry in GET /team/positions, so setting it in one place sets it everywhere.
| Route | Scope | Answer |
|---|---|---|
GET /team/legislation/{record}/stance | team.positions.read | { recordId, matterId, stance, label, updatedAt, history }; history: newest first, { stance, previous, source, at, by } (stance: null = cleared). 404 when your team has no stance on it. |
PUT /team/legislation/{record}/stance | team.positions.write (owners, admins) | Body { "stance": "supports" }. Sets it (and adds the bill to your positions). |
DELETE /team/legislation/{record}/stance | team.positions.write (owners, admins) | Clears it. 404 when there was none. |
GET /team/legislation/{record}/targets | team.positions.read | Advocacy teams: { onTargetList, contacted } (person ids). |
POST /team/legislation/{record}/targets | team.positions.write | Body { "personIds": [...] } (up to 50). Adds them to the list. |
DELETE /team/legislation/{record}/targets/{personId} | team.positions.write | Takes one member off. 404 when they weren't on it. |
curl -X PUT "https://www.chicagopolitics.org/api/v2/team/legislation/O2026-0001234/stance" \
-H "Authorization: Bearer $CHICAGO_POLITICS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7d1c6a52-stance-1" \
-d '{"stance": "opposes"}'
OAuth apps
Apps can ask for the same scopes with OAuth 2.1: send scope (space-separated) to /oauth/authorize. The consent screen lists them by name and, for team scopes, lets the person pick Personal or one of their teams (where they may make team keys). The app gets only what that account can grant; the token response has the granted scope and, for a team, org_id. A refresh may send a narrower scope, never a wider one. No scope (or api) means public.read public.ai, as before. People see and disconnect apps under Settings → API keys.
https://www.chicagopolitics.org/oauth/authorize?response_type=code&client_id=<id>&redirect_uri=<uri>
&code_challenge=<S256 challenge>&code_challenge_method=S256
&scope=public.read%20team.notes.read%20team.follows.write