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
- A metric's absence for a year is missing data, never zero — check
data_years. - Comparing two years only makes sense for metrics remeasured in both; a vintage value carried forward is the same measurement, not new evidence.
- Most metrics are allocated to wards from finer geography, so treat small differences as
noise.
coverage.populated_ward_counttells you how many wards a metric actually reaches. - Ward boundaries changed in May 2023. Values are mapped to today's boundaries wherever the source allows it.
- The data is open — attribution to Ward Wise Penlight is appreciated, and corrections are more than appreciated.