# Zillow Property Scraper — Agent Details & Price History (`thenetaji/zillow-property-scraper`) Actor

Look up any Zillow home by property ID or link. Take the whole record, including the listing agent's name, email and phone number, the Zestimate, the taxes, the description and every photo. Or take its history instead: every price cut and sale, the tax bill by year, schools and comparable homes.

- **URL**: https://apify.com/thenetaji/zillow-property-scraper.md
- **Developed by:** [The Netaji](https://apify.com/thenetaji) (community)
- **Categories:** Real estate, Lead generation, Business
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.40 / 1,000 results

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Zillow Property Scraper

Everything Zillow holds on a home, from its property ID or a link to it. There are two jobs here,
and `scraperType` picks which one a run does.

**Who is selling this house, and what is it?** The property record carries the listing agent's name,
email address and telephone number, the brokerage and the MLS, the price and the Zestimate, the tax
bill, the structure and location, the listing description, every photograph at full width, the
complete RESO fact set, and the page view, save and tour counts Zillow publishes nowhere a visitor
can see. The contact details and the demand counts are the part no Zillow page renders.

**What has this house done over time?** The history record carries every price event Zillow records
for the home, the assessed value and tax bill year by year, the schools attached to it, and the
comparable homes nearby.

Both jobs take the same list of homes and cost one request per home, which is why they are one
Actor rather than two.

### Accepted input

`zpid` is the list of homes to read. Zillow property links and bare property identifiers are both
accepted and may be mixed in the same list; the same home given twice under two spellings is read
once. Every row produced by
[Zillow Search Scraper](https://apify.com/thenetaji/zillow-search-scraper) and
[Zillow Sitemap Scraper](https://apify.com/thenetaji/zillow-sitemap-scraper) carries a `zpid` field
holding exactly the value this field accepts, so a discovery run feeds into this Actor unchanged.

`scraperType` is `propertyDetails` by default, and `propertyHistory` for the second mode.

```json
{
  "scraperType": "propertyDetails",
  "zpid": [
    "2146999031",
    "https://www.zillow.com/homedetails/2146999031_zpid/"
  ]
}
```

The Actor takes an identifier rather than an address on purpose. The address half of a Zillow
property link is decorative: `/homedetails/2146999031_zpid/` and
`/homedetails/any-slug-at-all/2146999031_zpid/` both resolve to the same home. A link assembled from
an address is therefore not validated by anything, and it fails silently by returning a different
house rather than by returning nothing.

### Response fields: the property record

```json
{
  "zpid": "2146999031",
  "price": 480000,
  "zestimate": 468600,
  "rent_zestimate": 1936,
  "last_sold_price": 43000,
  "bedrooms": 3,
  "bathrooms": 2,
  "living_area": 1600,
  "year_built": 1911,
  "county": "Cook County",
  "agent_name": "Luis Ortiz",
  "agent_phone": "708-990-4079",
  "broker_name": "RE/MAX Partners",
  "mls_name": "MRED as distributed by MLS GRID",
  "page_view_count": 197,
  "favorite_count": 20,
  "days_on_zillow": 23,
  "photo_count": 61
}
```

Who is selling the home arrives as `agent_name`, `agent_email`, `agent_phone`, `broker_name`,
`broker_phone`, `mls_id`, `mls_name`, `listing_agreement` and `true_status`. None of these is
rendered on the Zillow page itself. `agent_email` is null more often than not; a telephone number is
stated far more consistently than an email address. `mls_id` is only unique inside the feed that
issued it, so it should be read together with `mls_name`. `true_status` is the MLS's own status word
before Zillow mapped it onto its own smaller set, which is not always the same value as
`home_status`.

How much attention the listing is getting arrives as `page_view_count`, `favorite_count` and
`tour_view_count`. A save is a logged-in action by somebody who intends to return, so its ratio to
the view count behaves as a conversion rate for the individual home. All three are worth reading
against `days_on_zillow`; 197 views over 23 days describes a different listing from 197 views in one.

The money fields are `price`, `currency`, `zestimate`, `rent_zestimate`, `last_sold_price`,
`date_sold`, `price_change` and `price_change_date_display`, with `tax_annual_amount`,
`property_tax_rate` and `monthly_hoa_fee` beside them. A null `monthly_hoa_fee` means the home has no
association, which is a different statement from a fee of zero. The Zestimate is a model output
rather than a listed figure and is stated even for homes that are not for sale, so the gap between it
and `price` is Zillow's opinion rather than a fact about the market.

The structure arrives as `bedrooms`, `bathrooms`, `living_area`, `lot_size`, `lot_area_value`,
`lot_area_units`, `year_built`, `home_type`, `property_type`, `listing_type` and
`contingent_listing_type`. `lot_size` and `lot_area_value` describe the same lot in different units;
`lot_area_units` names the second one.

The location arrives as `street_address`, `city`, `state`, `zipcode`, `country`, `county`,
`neighborhood`, `community`, `subdivision`, `latitude`, `longitude` and `time_zone`. `county` is
stated here and nowhere else in the response, and it is the join key for public tax and deed records.
`neighborhood` is frequently null, which means Zillow names none rather than that the home has none.

`photos` holds every photograph of the home at the widest size available, deduplicated; 61 were
returned for the sampled property. `photo_count` counts what actually arrived rather than repeating
a figure the page printed.

`reso_facts` is republished whole, under Zillow's own key names such as `bathroomsThreeQuarter` and
`garageParkingCapacity`, and is the one field in the response that does not follow this Actor's
naming. RESO is the real-estate industry's own interchange standard and its field names are the
format, so renaming them would destroy the portability that makes the object worth carrying.
The object is also flat, wide and variable, at roughly a hundred facts of which any listing carries a
different subset, so a fixed shape would either invent columns or drop real ones.

### Response fields: the history record

```json
{
  "zpid": "2146999031",
  "street_address": "2114 S Trumbull Ave",
  "city": "Chicago",
  "price_history": [
    { "date": "2025-08-01", "price": 480000, "event": "Price change", "price_change_rate": -0.03 }
  ],
  "tax_history": [
    { "time": 2024, "tax_paid": 3210, "value": 162000, "tax_increase_rate": 0.041 }
  ],
  "schools": [
    { "name": "Marshall Elementary", "rating": 3, "level": "Elementary", "grades": "PK-8", "distance": 0.4 }
  ],
  "nearby_homes": [
    { "zpid": "2077663766", "price": 415000, "home_status": "SOLD", "bedrooms": 3, "bathrooms": 2 }
  ]
}
```

`price_history` holds every listing, price cut and sale Zillow records for the home, newest first,
each with its `date`, `price`, `event`, `price_change_rate` stated as a rate rather than a
percentage, and `source`. `tax_history` holds the assessed value and tax bill by year with
`tax_increase_rate` and `value_increase_rate`; the assessed value is what the county taxes the home
on, and it is neither the market value nor the Zestimate. `schools` holds `name`, `rating` out of ten,
`level`, `grades`, `distance` in miles, `assigned` and `link`. `nearby_homes` holds comparable homes,
each with its own `zpid`, so a comparables set is a starting point rather than a dead end.

The four lists are returned on one row rather than split into four datasets, because they belong to
the same home and are read in one request. Splitting them would produce a spreadsheet in which the
price events and the tax years no longer line up with the house they describe.

An empty list is a real and complete answer meaning Zillow holds no such records; one San Francisco
property I measured returns zero price events beside 25 tax rows, while a Chicago property returns 22
price events and 26 tax rows. A null on one of these fields is the rarer case in which the response
did not carry the list at all. The two are kept distinct rather than flattened together, because
otherwise "this home has never changed price" and "this did not arrive" become the same cell.

### Questions

**Why can the record and the history not arrive on one row?**
Because they come from two different documents. The page a property record is read from does not
contain the price history, the tax history, the schools or the comparables; they are genuinely absent
from it rather than withheld. Collecting them is therefore a second read, and I made it a mode rather
than a checkbox so that a run reading agent contact details cannot quietly pay for a tax history
nobody asked for. Running the same list twice and joining on `zpid` returns both.

**One identifier produced no row, and the run did not fail. Why?**
Zillow served no home for it; either the identifier never existed, or the listing has been withdrawn.
That home is skipped with a line in the run log and the remainder of the list continues. Twenty
identifiers read from a listing last week will not all still resolve, and losing the other nineteen
rows to one dead identifier is the wrong trade.

**Why does `assigned` say null for a school that is obviously the local one?**
Zillow states that field only where the district publishes attendance boundaries it trusts. Null
means Zillow does not say, rather than that the home is unassigned to the school.

**Why is there no error column on the history record?**
Because it would be non-empty on almost every row while meaning nothing. Zillow answers this
request with partial errors beside complete data as a matter of course; every response I have
measured carried `Failed to fetch visualizations` while also returning 22 price events and 26 tax
rows. A column reading "error" next to a full price history is a column somebody filters on, and
filtering on it would discard the good rows. So it is not published, and the four lists are judged
on their own contents. A request that genuinely failed does not produce a row at all: the
identifier is skipped with a line in the run log.

**Can prices be compared directly across `nearby_homes`?**
Not without reading `home_status` on each row first. The set mixes for-sale, sold and off-market
homes, so a `price` there is an asking price on some rows and a sale price on others.

**Why are some fields empty on a sold home?**
Not every record states every field, and a sold or off-market home states fewer than a live listing;
`description` in particular is normally absent once a listing closes. Fields the record does not
state are returned as null rather than omitted, so a column never silently changes width and "Zillow
states nothing here" stays distinguishable from "the Actor did not look".

**Is a Zillow account, cookie or API key required?**
No. No account, session cookie or key of any kind is supplied to the Actor or needed by it.

### Related Actors

[Zillow Search Scraper](https://apify.com/thenetaji/zillow-search-scraper) starts from a city, state
or ZIP code rather than from a list of identifiers, and can merge the property record onto each
listing row in the same run.

[Zillow Sitemap Scraper](https://apify.com/thenetaji/zillow-sitemap-scraper) supplies property
identifiers in bulk from Zillow's published index, which is the cheapest way to assemble the list
this Actor consumes.

# Actor input Schema

## `scraperType` (type: `string`):

Choose the dataset for this run, then fill in the section for that mode below.

## `zpid` (type: `array`):

One or more homes to fetch, given as Zillow property links or as bare property IDs. Both forms are accepted and may be mixed in one list. Every row a search or sitemap run produces carries the identifier this field takes.

## Actor input object example

```json
{
  "scraperType": "propertyDetails",
  "zpid": [
    "2146999031",
    "https://www.zillow.com/homedetails/2146999031_zpid/"
  ]
}
```

# Actor output Schema

## `dataset` (type: `string`):

All records scraped by this run

# 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 = {
    "scraperType": "propertyDetails",
    "zpid": [
        "2146999031"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("thenetaji/zillow-property-scraper").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 = {
    "scraperType": "propertyDetails",
    "zpid": ["2146999031"],
}

# Run the Actor and wait for it to finish
run = client.actor("thenetaji/zillow-property-scraper").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 '{
  "scraperType": "propertyDetails",
  "zpid": [
    "2146999031"
  ]
}' |
apify call thenetaji/zillow-property-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,thenetaji/zillow-property-scraper"
        }
    }
}
```

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/SFdesmGTQ5tAAP5ir/builds/HW3hmiUxJC31a5osu/openapi.json
