# Cook County Assessment Appeals - Values & Board Decisions (`j0401/cook-assessment-appeals`) Actor

Cook County assessment records keyed on one parcel id (public open data, 59.8M rows): a parcel's value as mailed, certified and Board-revised; every appeal case with type, status and reasons; and Board of Review decisions showing the Assessor's value against the Board's and the outcome.

- **URL**: https://apify.com/j0401/cook-assessment-appeals.md
- **Developed by:** [Wenhao Yang](https://apify.com/j0401) (community)
- **Categories:** Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.06 / 1,000 cook county assessment records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Cook County Assessment Appeals - Values & Board Decisions

Every parcel in Cook County gets an assessed value, and every assessment can be challenged - first with the Assessor, then with the Board of Review. This actor turns the county's assessment record into a **charged-per-record search, filter and aggregate tool** across three datasets that all key on the same parcel id: the values, the appeals against them, and the Board's decisions.

**Built for:** property tax appeal and protest work, assessment-ratio and equity studies, appraisal and valuation research, portfolio due diligence, and anyone who needs **the county's own assessment record** rather than a portal lookup one PIN at a time.

### What it covers

Three corpora, all joined on the **14-digit parcel index number (PIN)** - an exact key, not a name match. One parcel's whole assessment history lines up on one id.

| Corpus | Rows | Years | One row is |
|---|---|---|---|
| `values` | **50,670,551** | 1999-2026 (28) | a parcel's assessed value on one roll |
| `appeals` | **2,646,399** | 1999-2026 (14) | an appeal case against a parcel |
| `decisions` | **6,933,428** | 2010-2025 (16) | a Board of Review decision on an appeal |

Depth beyond a value figure:

- **Three valuation stages on one row.** The values corpus carries the same parcel's assessment as **mailed** (first notice), as **certified** (after Assessor review) and as **revised by the Board of Review** - so the effect of each stage is visible in the record itself.
- **The appeal case in full.** Type (residential / condo-coop / commercial / land / incentive / omitted), status, whether the value changed, up to three coded reasons with their descriptions, and the **agent** who filed it.
- **The outcome, quantified.** Board decisions carry the Assessor's value against the Board's revised value side by side (`assessorTotalValue` vs `boardTotalValue`) with the result: **Decrease 3,194,842 / No Change 3,733,475 / Increase 5,111**.

### The fine print that matters

**The appeals corpus has a hole in it, and it is the county's, not ours.** The source states in its own description that appeals "for many years are currently missing from this dataset as we repopulate our system of record", with appeals from 2021 on expected to remain available. Measured 2026-09-17 that is exactly what the data shows: **1999-2004 are present, 2005-2020 are essentially absent** (2014 has a single row, 2020 has 27), and **2021-2026 are present and full**. So a year filter on 2015 returns an empty result - which is correct - and this actor validates the year against the years the corpus actually holds, so you get an explicit message telling you so rather than a silently empty answer that looks like a bug.

**A profile is the join.** `mode=profile` with a PIN returns that parcel's values, appeal cases and Board decisions in one response. Because the three datasets share the PIN, this is an exact join, and it traces a whole appeal chain: one Barrington parcel was mailed **$104,500** for 2025, certified at the same figure, then **reduced by the Board to $70,470** - and its 2026 mailed value is **$70,470**, carrying the decision forward into the next roll.

The three corpora carry **different year spans** (28 / 14 / 16), so a year valid in one may not exist in another - and a filter belonging to one corpus (say `result`, which only Board decisions have) is **rejected** when set against another, rather than silently returning unfiltered rows.

Value fields are returned as the county publishes them, including the **three separate exemption columns** the source keeps apart. On the newest roll the assessment is still moving through the cycle: the **Board** column is empty on all 1,863,562 parcels because no Board review has completed, while the **certified** column is populated on **367,712** of them (19.7%) as certifications land through the year. That is the county's own state of progress, not a gap in the data.

### Typical questions

- "**Did this parcel win its appeal**, and by how much?" - one PIN, values plus decisions.
- "**Board decisions that reduced** the assessment, by class or township."
- "Every **appeal filed against** a parcel, with the agent and the reasons cited."
- "A parcel's assessed value at the **mailed, certified and Board stages**."
- "All appeals handled by a given **agent or tax firm**."
- "Aggregate decisions by **outcome**, **major class** or **year**."

### Inputs

| Input | What it does |
|---|---|
| `mode` | `rows` (default) / `aggregate` / `profile` |
| `corpus` | `values` (default) / `appeals` / `decisions` |
| `pin` | exact 14-digit parcel id - required for `profile` |
| `year` | roll year; blank = newest roll of that corpus |
| `township` / `class` | exact township code / property class |
| `result` / `majorClass` | Board outcome (Decrease / No Change / Increase) and major class - decisions only |
| `status` / `appealType` / `change` / `hearingType` / `agent` | appeal case fields - appeals only |
| `appellant` | the appealing party - decisions only |
| `groupBy` | aggregate over tax year / class / township / appeal type / status / result / major class |
| `maxResults` | cap records (default 50) |

**Default run = 50 records from the newest roll** - fast for the daily auto-test. For a targeted query add a filter; for a whole parcel use `profile`; for a broad view use `aggregate`.

### Example inputs

**One parcel's whole appeal chain** - `mode=profile` keys on the PIN and returns
that parcel's values, its appeal cases and its Board decisions together.

```json
{ "mode": "profile", "pin": "01011000020000", "maxResults": 50 }
```

**Board decisions that cut the assessment**

```json
{ "corpus": "decisions", "result": "Decrease", "maxResults": 5 }
```

**Appeal cases that have closed**

```json
{ "corpus": "appeals", "status": "closed", "maxResults": 5 }
```

**Decisions by outcome** - one row per `result`.

```json
{ "mode": "aggregate", "corpus": "decisions", "groupBy": "result" }
```

### Low cost

**From $0.0001 per record, down to $0.00006 at Gold** Pay-per-event: you are charged per record delivered, and nothing for the query. Cost scales with what you pull, not with the size of the datasets, and each record is metered individually - a full parcel profile costs a fraction of a cent.

### Example output

**One parcel on one roll** - `mode=profile`, `pin=01011000020000` returns the
values row below alongside that parcel's appeal cases and Board decisions:

```json
{
  "platform": "cook-assessment-appeals",
  "source": "cook-county-values",
  "corpus": "values",
  "mode": "rows",
  "groupKey": "",
  "groupCount": "",
  "groupBy": "",
  "agentCode": "",
  "agentName": "",
  "appealCertifiedBuilding": "",
  "appealCertifiedLand": "",
  "appealCertifiedTotal": "",
  "appealMailedBuilding": "",
  "appealMailedLand": "",
  "appealMailedTotal": "",
  "appealSeq": "",
  "appealTrack": "",
  "appealType": "",
  "appealTypeCode": "",
  "appealTypeDescription": "",
  "appellant": "",
  "assessorImprovementValue": "",
  "assessorLandValue": "",
  "assessorTotalValue": "",
  "boardBuilding": "",
  "boardHomeownerExemption": "",
  "boardImprovementValue": "",
  "boardLand": "",
  "boardLandValue": "",
  "boardTotal": "",
  "boardTotalValue": "",
  "caseNumber": "",
  "certifiedBuilding": "",
  "certifiedHomeownerExemption": "",
  "certifiedLand": "",
  "certifiedTotal": "",
  "change": "",
  "changeReason": "",
  "class": "592",
  "hearingType": "",
  "mailedBuilding": "33591.0",
  "mailedHomeownerExemption": "0.0",
  "mailedLand": "36879.0",
  "mailedTotal": "70470.0",
  "majorClass": "",
  "neighborhoodCode": "10012",
  "noChangeReason": "",
  "noChangeReasonDescription": "",
  "pin": "01011000020000",
  "reasonCode1": "",
  "reasonCode2": "",
  "reasonCode3": "",
  "reasonDescription1": "",
  "reasonDescription2": "",
  "reasonDescription3": "",
  "result": "",
  "sourceUpdatedAt": "2026-09-15",
  "status": "",
  "subkey": "",
  "taxCode": "",
  "taxYear": "2026",
  "townshipCode": "10",
  "townshipName": "Barrington",
  "volume": ""
}
```

**`mode=aggregate`, `corpus=decisions`, `groupBy=result`** - one row per outcome:

```
Decrease       3,194,842
No Change      3,733,475
Increase           5,111
```

# Actor input Schema

## `mode` (type: `string`):

rows = records matching your filters (default). aggregate = one count row per group (see groupBy). profile = one parcel's values, appeals and Board decisions together (requires pin).

## `corpus` (type: `string`):

values = assessed values at three stages (28 roll years, 1999-2026). appeals = appeal cases with type, status and reasons (14 years - 2005-2020 are missing while the County repopulates, see the README). decisions = Board of Review decisions with the Assessor's vs the Board's value and the outcome (16 years, 2010-2025).

## `pin` (type: `string`):

Exact 14-digit parcel index number, e.g. '08142130040000'. Required for profile mode. Blank = any.

## `year` (type: `string`):

Roll year. Blank = the newest roll of the chosen corpus. The three corpora carry different spans, so a year one corpus has may not exist in another.

## `township` (type: `string`):

Exact township code, e.g. '16'. Blank = any.

## `class` (type: `string`):

Exact property class code, e.g. '203' (values and appeals corpora only). Blank = any.

## `result` (type: `string`):

The Board of Review's outcome (decisions corpus only): Decrease (~3.19M), No Change (~3.73M), Increase (~5.1k). Blank = any. Aggregate counts for this dimension cover the newest year's roll unless a year is passed.

## `majorClass` (type: `string`):

The property's major class (decisions corpus only), matched exactly - 'Multi Family' and 'Multi Family Incentive' are separate classes. Blank = any. Aggregate counts for this dimension cover the newest year's roll unless a year is passed.

## `status` (type: `string`):

Appeal case status (appeals corpus only). Blank = any.

## `appealType` (type: `string`):

The kind of appeal filed (appeals corpus only). Blank = any.

## `change` (type: `string`):

Whether the appeal moved the value, as recorded on the case (appeals corpus only). Blank = any. Aggregate counts for this dimension cover the newest year's roll unless a year is passed.

## `hearingType` (type: `string`):

Whether the case covers this year alone (2,624,512) or also prior years (21,887) - appeals corpus only. Blank = any.

## `agent` (type: `string`):

The representative who filed the appeal - attorney or tax firm (appeals corpus only). Blank = any.

## `appellant` (type: `string`):

The party who appealed to the Board of Review (decisions corpus only). Blank = any.

## `groupBy` (type: `string`):

Which dimension to aggregate over. Not every dimension exists in every corpus - an incompatible combination is reported as an error rather than returning an unfiltered result. Only 'taxYear' spans every year of its corpus; any other dimension is counted on the newest year's roll unless you pass an explicit year.

## `maxResults` (type: `integer`):

Cap the number of records pushed in rows mode (0 = up to ~10k per run; each record is metered individually, so there is no per-run charge cap). Aggregate mode returns every group.

## Actor input object example

```json
{
  "mode": "rows",
  "corpus": "values",
  "pin": "",
  "year": "",
  "township": "",
  "class": "",
  "result": "",
  "majorClass": "",
  "status": "",
  "appealType": "",
  "change": "",
  "hearingType": "",
  "agent": "",
  "appellant": "",
  "groupBy": "taxYear",
  "maxResults": 50
}
```

# Actor output Schema

## `recordsUrl` (type: `string`):

Cook County assessment values, appeals and Board decisions - as JSON

## `datasetUrl` (type: `string`):

No description

## `runUrl` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("j0401/cook-assessment-appeals").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("j0401/cook-assessment-appeals").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call j0401/cook-assessment-appeals --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,j0401/cook-assessment-appeals"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/Qxcgt4dTUOppK0ucR/builds/9i43wBuESXI9Dxdvs/openapi.json
