Developers

Penlight API

Two read-only endpoints over the same data the dashboard uses: one ward's numbers in context, and the dictionary that explains what every metric means. Ask for a key →

Authentication

Every request needs a key, sent as a header. Keys are issued by request — they're free, we just like knowing who's building on this.

curl -H "X-API-Key: wwp_1a2b3c4d_…" \
  https://penlight.wardwise.org/api/v1/wards/12

Authorization: Bearer <token> works too. Never put a key in a query string — it would end up in logs and browser history. A missing key returns 401 api_key_required; a revoked one returns 403 api_key_invalid.

GET /api/v1/wards/{ward}

Every published metric for one ward, each beside the average ward and this ward's rank.

Path ward — ward number, 1 or 01.
Query year — a four-digit year, or latest (default). Unavailable years return 404 year_not_available with the list of years that exist.

{
  "ward": { "ward_id": "12", "ward_number": 12,
            "neighborhoods": ["Brighton Park", "Mckinley Park", "New City"] },
  "year": "latest",
  "available_years": ["2003", "2004", "…", "2026"],
  "metric_count": 96,
  "metrics": [
    {
      "metric_id": "c311_response_days",
      "label": "311 Response Time",
      "unit": "days", "direction": "lower", "category": "good_governance",
      "value": 10.24,
      "ward_average": 9.31,
      "rank": 34, "wards_measured": 50,
      "normalized_score": 41.7
    }
  ]
}

ward_average is the unweighted mean across wards that have a value — the average ward, not a population-weighted citywide figure. rank counts from the good end for that metric's direction, so 1 is always best. normalized_score is the 0–100 position within that year's distribution, where higher is always better regardless of direction.

GET /api/v1/metrics

What each metric measures, where it comes from, and which years it covers.

{
  "metric_count": 137,
  "data_years": [2003, 2004, "…", 2026],
  "metrics": [
    {
      "metric_id": "c311_response_days",
      "label": "311 Response Time",
      "description": "How many days the city takes to close common 311 requests here.",
      "category": "good_governance",
      "unit": "days", "direction": "lower",
      "source": "Chicago 311 service requests",
      "calculation": "One value per calendar year since 2011: median days-to-close …",
      "methodology_short": "Median days to close six common 311 request types …",
      "time_invariant": false, "valid_from": null,
      "data_years": [2011, 2012, "…", 2025],
      "coverage": { "status": "populated", "populated_ward_count": 50 },
      "nominator": { "name": "Connor Florczyk", "ward_id": "42" }
    }
  ]
}

direction is higher, lower, or neutral — it tells you which way is good, which you need before ranking anything yourself. data_years lists the years actually measured; a gap means no measurement, not a zero.

Using it honestly