HouseData

Cookbook

Zillow Full Data API

Five workflows you can paste and run. The endpoint reference says what each call returns; this shows how they combine into the things people build. Every recipe is a few calls and a little glue.

All examples use one tiny helper — set your key once:

helper.py — the three functions every recipe uses
import requests

HOST = "zillow-full-data-api.p.rapidapi.com"
KEY  = "YOUR_RAPIDAPI_KEY"
HEAD = {"X-RapidAPI-Key": KEY, "X-RapidAPI-Host": HOST}

def get(path, **params):
    r = requests.get(f"https://{HOST}{path}", headers=HEAD, params=params)
    r.raise_for_status()
    return r.json()

def post(path, **body):
    r = requests.post(f"https://{HOST}{path}", headers=HEAD, json=body)
    r.raise_for_status()
    return r.json()

def search_all(location, status="forSale", **extra):
    """Every listing for a place, up to Zillow's 1,000-per-search ceiling."""
    for page in range(1, 26):
        result = post("/search", location=location, status=status, page=page, pageSize=40, **extra)
        items = result["searchResults"]
        if not items:
            return
        yield from items

Your key is the one on the listing's Endpoints tab; the host is zillow-full-data-api.p.rapidapi.com.

1. City inventory snapshot

Build: the numbers a market report opens with — count, median price, median $/sq ft, median days on market — for a ZIP, today.

Recipe 1
import statistics as st

rows = [i["property"] for i in search_all("78704", fields="core") if i["resultType"] == "property"]
prices = [p["price"]["value"] for p in rows if p.get("price", {}).get("value")]
ppsf   = [p["price"]["pricePerSquareFoot"] for p in rows if p.get("price", {}).get("pricePerSquareFoot")]
dom    = [p["daysOnZillow"] for p in rows if p.get("daysOnZillow") is not None]

print(len(rows), "for sale |", "median $", st.median(prices), "| median $/sqft", st.median(ppsf),
      "| median days on market", st.median(dom))

Run it weekly and you have a time series. fields="core" keeps the pages small.

2. Investor screen — price cuts and long days on market

Build: the listings that have cut their price and sat a while — the ones worth a call.

Recipe 2
hits = []
for item in search_all("Austin, TX", fields="core"):
    p = item["property"]
    if item["resultType"] != "property":
        continue
    cut = p.get("price", {}).get("priceChange")           # negative on a cut
    if cut and cut < 0 and (p.get("daysOnZillow") or 0) >= 60:
        hits.append((p["zpid"], p["address"]["streetAddress"], p["price"]["value"], cut,
                     p["daysOnZillow"]))

hits.sort(key=lambda h: h[3])                              # deepest cut first
for h in hits[:20]:
    print(h)

Add listing.listingSubType to the projection (fields="core,listing") to filter FSBO or open-house listings the same way.

3. Rental comps by building

Build: what the buildings in a ZIP charge, per bedroom count — the table a leasing team wants.

Recipe 3
table = {}
for item in search_all("78704", status="forRent"):
    p = item["property"]
    if item["resultType"] != "propertyGroup":                 # buildings only
        continue
    for u in p.get("unitsGroup", []):
        table.setdefault(u["bedrooms"], []).append((u["minPrice"], p["title"]))

for beds in sorted(table):
    prices = sorted(x[0] for x in table[beds])
    print(f"{beds} bd: {len(prices)} buildings, from ${prices[0]} to ${prices[-1]}, median ${prices[len(prices)//2]}")

Single homes and units in the same search come back as resultType: "property"; include them for a full market view.

4. Watchlist — diff a set of homes on a schedule

Build: a daily job that tells you when a watched home changes price, status or Zestimate.

Recipe 4
import json, pathlib

WATCH = ["64947237", "29558116", "83816995"]
state_file = pathlib.Path("watch.json")
old = json.loads(state_file.read_text()) if state_file.exists() else {}

queries = [{"type": "property", "zpid": z, "fields": "core"} for z in WATCH]
res = post("/batch", queries=queries)["results"]
new = {}
for z, r in zip(WATCH, res):
    if not r["ok"]:
        print(z, "->", r["status"], r["error"]); continue
    d = r["data"]
    new[z] = {"price": d["price"], "status": d["status"], "zestimate": d["zestimate"]}
    if z in old and old[z] != new[z]:
        print("CHANGED", z, d["address"]["street"], old[z], "->", new[z])

state_file.write_text(json.dumps(new))

One batch call per run, whatever the list size (chunk to your plan's batch size past that).

5. CRM enrichment — zpids or Zillow links in, full records out

Build: a spreadsheet column of Zillow links becomes agent, broker, Zestimate and facts.

Recipe 5
import csv, re

links = [row["zillow_url"] for row in csv.DictReader(open("leads.csv"))]
zpids = [m.group(1) for u in links if (m := re.search(r"(\d+)_zpid", u))]

out = []
for i in range(0, len(zpids), 10):                          # 10 = Basic/Pro batch size
    chunk = zpids[i:i + 10]
    batch = post("/batch", queries=[{"type": "property", "zpid": z} for z in chunk])
    for z, r in zip(chunk, batch["results"]):
        if r["ok"]:
            d = r["data"]; a = d.get("agent") or {}; b = d.get("broker") or {}
            out.append([z, d["address"]["street"], d["price"], d["zestimate"],
                        a.get("name"), a.get("phone"), b.get("name")])
        else:
            out.append([z, None, None, None, None, None, r["error"]])

header = ["zpid", "street", "price", "zestimate", "agent", "phone", "broker"]
csv.writer(open("leads_enriched.csv", "w")).writerows([header, *out])

Off-market links work too — every zpid has a record; the agent fields are simply null where no listing exists.

Two habits that keep this boring (the good kind)

  • Read resultType on every search item — a rental search mixes buildings and homes, and they carry
  • Treat a 404 as an answer and a 502/503 as a retry. The error object tells you which, and carries a