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.

ScopeKeyGives
public.readanyPublic data: matters, votes, meetings with insights, alderpersons, money and lobbying (and all of v1)
public.aianyAI answers (v1 POST /answers, GraphQL answer, MCP, A2A)
me.follows.readpersonalGET /me/follows: what you follow
me.follows.writepersonalPOST /me/follows, DELETE /me/follows/{id}
team.follows.readteamGET /team/follows
team.follows.writeteam (owners, admins)POST /team/follows, DELETE /team/follows/{id}
team.notes.readteamGET /team/notes: notes shared with the team
team.notes.writeteamPOST /team/notes, PATCH / DELETE /team/notes/{id} (your own notes)
team.positions.readteamGET /team/positions; GET /team/legislation/{record}/stance and /targets
team.positions.writeteam (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.readteamGET /team/dashboards: Analytics dashboards shared with the team
team.dashboards.writeteamPOST /team/dashboards, PATCH / DELETE /team/dashboards/{id}
people.readteamGET /people, /people/{id}, /meetings/{id}/testimony: officials, City staff and lobbyists
people.private.readteam (offices)Adds residents and organization representatives to the people routes

Requests

curl "https://www.chicagopolitics.org/api/v2/team/follows" \
  -H "Authorization: Bearer $CHICAGO_POLITICS_KEY"

Writing

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.

RouteScopeAnswer
GET /team/legislation/{record}/stanceteam.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}/stanceteam.positions.write (owners, admins)Body { "stance": "supports" }. Sets it (and adds the bill to your positions).
DELETE /team/legislation/{record}/stanceteam.positions.write (owners, admins)Clears it. 404 when there was none.
GET /team/legislation/{record}/targetsteam.positions.readAdvocacy teams: { onTargetList, contacted } (person ids).
POST /team/legislation/{record}/targetsteam.positions.writeBody { "personIds": [...] } (up to 50). Adds them to the list.
DELETE /team/legislation/{record}/targets/{personId}team.positions.writeTakes 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