Real Estate Listing Data: Fields That Matter for Valuation
A valuation model needs a small core from each listing: price, living area, bedrooms, bathrooms, year built, lot size, property type, status and a location. The Zillow API's search endpoint returns all of those as named JSON fields for 1 credit per call. What the sample doesn't show matters just as much. This post maps the fields to model inputs and flags what to distrust.
Which listing fields does a valuation model actually need?
A hedonic valuation model, which breaks a home into its characteristics and estimates the value each contributes, needs size, rooms, age, lot, type and location, plus a price target. All of them appear in a single search listing. The rest of the response (photo links, virtual tour flags, rental flags) describes how a listing is marketed, not what the home is worth.
Here is how the recorded fields map to model inputs:
| Model input | Field in the search response | Notes |
|---|---|---|
| Target price | price.value |
List price, in currency: usd |
| Price per area | price.pricePerSquareFoot |
Supplied in the response; recompute it from price.value and livingArea to check it |
| Size | livingArea |
Unit isn't documented; 7870 with a pricePerSquareFoot of 1779, and 2100 with 500, are both consistent with square feet |
| Bedrooms | bedrooms |
Integer |
| Bathrooms | bathrooms |
Integer in the sample |
| Age | yearBuilt |
Convert to age at the call date |
| Lot | lotSizeWithUnit.lotSize and lotSizeUnit |
The unit changes between listings: acres on the first, squareFeet on the second. Convert in code |
| Property type | propertyType |
singleFamily in the sample |
| Location | location.latitude, location.longitude, address.zipcode |
Coordinates for distance features, zip for grouping |
| Listing status | listing.listingStatus |
forSale on both sample listings |
| Marketing status | listing.marketingStatus |
active on the first listing, comingSoon on the second. A coming-soon price is a different label from an active asking price, so use this as a status feature or filter on it |
| Assessed value | taxAssessment.taxAssessedValue and taxAssessmentYear |
949000 and "2025" on the second listing only. An assessed value, not the annual tax bill; keep the year with it |
| Zillow's estimate | estimates.zestimate |
13068300 on the first listing only. Don't train on it: see below |
The Zillow API has ten endpoints. The search endpoint is the one that returns this table in bulk; the Property endpoint takes zpid[] for individual homes.
What does the recorded response look like?
The recorded response from /v1/zillow/search nests everything under data.listings. Each listing has a zpid identifier, a location object, an address object and flat fields for size and type. Here is the request we recorded, centred on Seattle:
curl "https://api.perceptlayer.com/v1/zillow/search?latitude=47.6062&longitude=-122.3321&radius_mi=5&listing_category=category1&page_size=20" \
-H "X-API-Key: $API_KEY"
An excerpt of the first listing it returned:
{
"data": {
"listings": [
{
"zpid": 2068074790,
"location": { "latitude": 47.592564, "longitude": -122.386925 },
"address": {
"streetAddress": "1124 Sunset Avenue SW",
"zipcode": "98116",
"city": "Seattle",
"state": "WA"
},
…
"bathrooms": 6,
"bedrooms": 4,
"livingArea": 7870,
"yearBuilt": 2015,
"lotSizeWithUnit": { "lotSize": 0.2652, "lotSizeUnit": "acres" },
"propertyType": "singleFamily",
"listing": {
"listingStatus": "forSale",
"marketingStatus": "active",
…
},
"price": {
"value": 14000000,
"pricePerSquareFoot": 1779
},
"estimates": {
"zestimate": 13068300
},
…
And an excerpt of the second listing, which reports its lot in a different unit and carries a tax assessment instead of an estimate:
{
"zpid": 49046647,
…
"bathrooms": 2,
"bedrooms": 4,
"livingArea": 2100,
"yearBuilt": 1974,
"lotSizeWithUnit": { "lotSize": 3933.468, "lotSizeUnit": "squareFeet" },
"propertyType": "singleFamily",
"listing": {
"listingStatus": "forSale",
"marketingStatus": "comingSoon",
…
},
"price": {
"value": 1050000,
"pricePerSquareFoot": 500
},
"taxAssessment": {
"taxAssessedValue": 949000,
"taxAssessmentYear": "2025"
},
…
The paging fields sit at the end of the data object:
"count": 20,
"total_matching_count": 2058,
"page": 1,
"page_size": 20,
…
"paging_note": "page * page_size must not exceed 1000; up to 1000 rows come back in one request."
Keys are camelCase and the units are implicit, so write a small normalisation layer before anything reaches your model. For authentication and requests, see the docs on authentication and requests.
Which fields are noisy or missing in this sample?
Several fields need cleaning, and several valuation inputs aren't in the sample at all. The two recorded listings already disagree with each other: one reports its lot in acres and the other in square feet, one carries a Zillow estimate and the other a tax assessment, and one is active while the other is comingSoon. Two listings are an anecdote, not a benchmark, so check your own pulls.
Noisy fields
price.valueis what the seller asks. It is the right target for "what is on the market" and the wrong one for "what did homes sell for". For acomingSoonlisting it is an even earlier signal, so keeplisting.marketingStatusnext to it.price.pricePerSquareFootis supplied, but how it's computed isn't documented. In the sample, 14,000,000 divided by 7,870 is about 1,779 and 1,050,000 divided by 2,100 is exactly 500, both matching the supplied values, so recompute it fromprice.valueandlivingAreaif you want a single source of truth.titlereads "West Seattle L&I Co 1st Of" and "Scenic Heights Supl Plat" in the sample. Neither is an address or a description. Drop it.bathroomsis a count; whether it includes half baths isn't documented, so don't assume.lotSizeWithUnitcomes with a unit, and the unit varies. The first listing has 0.2652acres; the second has 3933.468squareFeet. A model that reads both as the same unit without checkinglotSizeUnitis wrong by a factor of 43,560.estimates.zestimateis Zillow's own estimate of value (13,068,300 against a list price of 14,000,000 on the first listing). It is useful as a benchmark to compare your model's output against. As a feature it makes the model circular: you would be training a valuation model partly on another valuation model's output, and the error you measure would look smaller than it is.
Fields the response doesn't show
The recorded response does include taxAssessment.taxAssessedValue (949000) and taxAssessmentYear ("2025") on the second listing. That is an assessed value, not annual property tax, and the first listing doesn't have the field at all. Neither listing in the recorded response shows annual property tax, HOA fees, sale history, renovation status, basement or garage details, or school quality. We haven't recorded those, so check a full response in the playground on the Property and Schools endpoint pages before you build on them.
How do you pull a clean training table?
Request listings by area, flatten the nested objects, normalise the lot size and drop anything you can't use. The code below calls the search endpoint with the same parameters as the recorded example, keeps the fields from the mapping table and prints the first two rows. It uses .get throughout, because a field missing on one listing shouldn't stop the batch. The Zestimate is stored for benchmarking only; leave it out of your feature list.
import os
from datetime import date
import requests
SQFT_PER_UNIT = {"acres": 43560, "squareFeet": 1}
resp = requests.get(
"https://api.perceptlayer.com/v1/zillow/search",
headers={"X-API-Key": os.environ["API_KEY"]},
params={
"latitude": 47.6062,
"longitude": -122.3321,
"radius_mi": 5,
"listing_category": "category1",
"page_size": 20,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()["data"]
rows = []
for item in data["listings"]:
price = (item.get("price") or {}).get("value")
living_area = item.get("livingArea")
if not price or not living_area:
continue # no target or no size: unusable for training
lot = item.get("lotSizeWithUnit") or {}
loc = item.get("location") or {}
listing = item.get("listing") or {}
tax = item.get("taxAssessment") or {}
unit_factor = SQFT_PER_UNIT.get(lot.get("lotSizeUnit"))
lot_size = lot.get("lotSize")
year_built = item.get("yearBuilt")
rows.append({
"zpid": item.get("zpid"),
"price": price,
"living_area": living_area,
"price_per_area": round(price / living_area),
"beds": item.get("bedrooms"),
"baths": item.get("bathrooms"),
"age": date.today().year - year_built if year_built else None,
# unknown unit -> None, never a silent guess
"lot_sqft": round(lot_size * unit_factor) if lot_size and unit_factor else None,
"type": item.get("propertyType"),
"lat": loc.get("latitude"),
"lon": loc.get("longitude"),
"zipcode": (item.get("address") or {}).get("zipcode"),
"status": listing.get("listingStatus"),
"marketing_status": listing.get("marketingStatus"),
"tax_assessed_value": tax.get("taxAssessedValue"),
"tax_year": tax.get("taxAssessmentYear"),
"zestimate": (item.get("estimates") or {}).get("zestimate"), # benchmark only
})
print(len(rows), "usable of", len(data["listings"]), "listings;",
data["total_matching_count"], "match the query")
for row in rows[:2]:
print(row)
Run against the recorded request, the first two rows are the two listings above, with lot_sqft about 11,552 for the first and 3,933 for the second, both in square feet. To narrow the pull, the search endpoint accepts min_price, max_price, min_beds, max_beds, living_area_min, lot_size_min and year_built_min, and home_statuses[] to select for example recentlySold. Parameters you can page with are page and page_size, and page * page_size must not exceed 1000. Errors come back with an HTTP status and a code; see the error reference.
How do you turn one listing into model features?
Take the second recorded listing, a typical house rather than the $14,000,000 outlier, and compute the derived features the table calls for, using a call date in 2026:
- Age: 2026 −
yearBuilt1974 = 52 years. - Lot in square feet:
lotSize3933.468 withlotSizeUnitsquareFeetneeds no conversion, so it stays about 3,933 square feet (roughly 0.09 acres). The first listing's 0.2652 acres would need × 43,560 = about 11,552 square feet. - Own price per area:
price.value1,050,000 ÷livingArea2,100 = 500. - Price against assessment:
price.value1,050,000 ÷taxAssessedValue949,000 = about 1.11, an assessment from 2025 compared with a 2026 asking price. - Status:
marketingStatuscomingSoon, so a flag separates it fromactivelistings.
The own price per area matches the supplied pricePerSquareFoot of 500. Computing it yourself means every row uses one formula, even when a field is missing or differs on other listings. For this row, the model sees a home 52 years old, on about 3,933 square feet, listed at 500 per unit of living area, before it hits the market as an active listing.
What does a pull cost?
Every call to the search endpoint costs 1 credit, and a failed call costs nothing. So the cost of a dataset is the number of calls, not the number of listings. The recorded request shows how fast that adds up: total_matching_count is 2,058, and paging_note says page * page_size must not exceed 1000, so one query returns at most 1,000 rows. Here are two worked examples:
- One area like the recorded one: 2,058 matches ÷
page_size20 = 103 pages, so at least 103 calls × 1 credit = 103 credits. You can't page past 1,000 rows in a single query, so split the area into slices that each match 1,000 or fewer (a smallerradius_mi, a bounding box, or bands ofmin_priceandmax_price). Uneven slices cost more calls than the minimum. - A neighbourhood-level refresh of 2,000 search calls × 1 credit = 2,000 credits. The free tier gives 1,000 credits a month, so 1,000 of those credits are free and the other 1,000 come from a pack.
The smallest pack is Starter, with 10,000 credits. You buy the whole pack, so the second 1,000 calls draw on it and the remaining 9,000 credits stay for later pulls. A monthly refresh of 10,000 calls × 1 credit = 10,000 credits fits one Starter pack.
Credits are prepaid and purchased credits last 12 months. See pricing for what credits cost and the credits docs. See rate limits before you pull many areas in parallel.
When is listing data not enough for valuation?
Listing data describes asking prices, so it is weakest for homes that aren't on the market and for homes whose condition drives the price. Test any model built on it against sold prices in your own market before you trust it.
What that means for your own model:
- Asking prices aren't sale prices. A model trained on asking prices learns what sellers hoped for. Add
recentlySoldlistings throughhome_statuses[]and a status feature (listing.marketingStatusis one), and inspect what a sold record contains first. - Zillow's estimate isn't independent.
estimates.zestimateis another valuation. Use it to sanity-check your output, not as an input. - Condition is a gap. Two identical-looking homes can differ by a full renovation. The Photos endpoint takes
zpid[], so you can pull images for a computer-vision step. The trade-off is a separate modelling project on top of the tabular one. - Outliers distort a small pull. The sample's first listing is a $14,000,000, 7,870-square-foot home with six bathrooms, a poor comparable for a typical house. Narrow the pull with
min_price,max_priceandmin_beds, at the cost of fewer rows per call. - Lending is a different bar. 12 CFR 323.3 requires an appraisal by a state certified or licensed appraiser for real estate-related financial transactions at institutions the FDIC regulates, with listed exemptions, so a model output doesn't replace one.
If you need area-level context rather than home-level inputs, the Market Data endpoint takes a required family and geography (one of City, County, Metro, National, Neighborhood, State, Zip). The Zillow API also has a /v1/zillow/zestimate-accuracy endpoint, which takes optional state and metro parameters. For a full walk-through of the search call, see Zillow Listings API: Get Property Data as JSON. More real estate endpoints are listed in real estate APIs.
Next step: get a free API key, open the Zillow API page and run the search call in the playground against your own market before you design features.
APIs in this post
Frequently asked questions
Which Zillow field should be the target for a valuation model?
Use price.value as the target only if you accept that it's an asking price. For a model of sale values, pull listings with home_statuses[]=recentlySold and inspect what the response returns for them. Don't mix active and sold prices in one training set without a status feature, and don't use estimates.zestimate as an input.
Can I get listing data for a whole city in one call?
No. The recorded request reports a total_matching_count of 2,058, but paging_note says page * page_size must not exceed 1000, so one query returns at most 1,000 rows. Split the area with a smaller radius_mi, a bounding box or price bands, then page each slice. At page_size 20, 2,058 matches need at least 103 calls, which is 103 credits.
How much does a valuation dataset cost to collect?
Each successful call costs 1 credit, and failed calls cost nothing. 2,000 calls use 2,000 credits. The free tier gives 1,000 credits a month after you verify an email address, with no credit card, so the other 1,000 come from a pack. The smallest, Starter, has 10,000 credits; see pricing for what credits cost.
Is the data complete and current?
The data is collected from publicly available sources and returned as found at the time of the call. We don't promise completeness or freshness. Fields can be absent on some listings, as taxAssessment and estimates are in the sample, so write code that tolerates missing values and record the date of each pull alongside the data.
What happens if I run out of credits mid-pull?
The service is prepaid, with no automatic top-up. When the balance runs out, calls return insufficient_credits (402) and nothing more is debited. Check the X-Credits-Remaining header on each response, and stop the loop on a 402 so you can resume after adding credits.