# 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](https://www.chicagopolitics.org/api/v2/openapi.json). 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 /me` needs no scope: your account, team, scopes, limits and usage today.
- Lists take `limit` (1-200) and `cursor` (from `meta.nextCursor`).
- Errors are `{ "error": { "code", "message" } }`, for example `scope_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](https://www.chicagopolitics.org/developers/rate-limits)).

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

## Writing

- A write needs its `.write` scope, 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 is `404`.
- 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 an `Idempotency-Key` header (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 is `422 idempotency_key_reused`.
- Dashboards: `PATCH` with `baseVersion` and `layout` answers `409 conflict` when someone saved the layout since; read it again and reapply.

```bash
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. |

```bash
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](https://www.chicagopolitics.org/developers/authentication): 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.

```text
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
```

---
Source: https://www.chicagopolitics.org/developers/v2 · Chicago Politics, an independent, nonpartisan tracker of the Chicago City Council. Guide for agents: https://www.chicagopolitics.org/llms.txt
