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:
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 itemsYour 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.
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.
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.
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.
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.
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
resultTypeon every search item — a rental search mixes buildings and homes, and they carry - Treat a
404as an answer and a502/503as a retry. The error object tells you which, and carries a