How to Monitor Zillow Price Drops and New Listings Daily
Run the same Zillow searches on a schedule and diff each run to surface price cuts and fresh inventory within hours. A daily price-drop alert pipeline.

Thirdwatch's Zillow Suite Scraper turns a set of ZIP codes into a repeatable daily snapshot of active listings — price,
price_change,price_reduction,days_on_zillow, beds, baths, area and the canonical listing URL, all keyed on a stablezpid. Schedule the same searches every morning, diff yesterday's snapshot against today's, and a brokerage or buyer-alert product surfaces price cuts and fresh inventory within hours of them hitting the site.
Why monitor Zillow price drops and new listings
Speed is the whole product for anyone selling listing alerts. A buyer's agent who calls a client about a $25,000 cut the morning it posts looks prescient; the same call three days later is noise, because the client already saw it in Zillow's own email. Price reductions are not a rare event either — Realtor.com's housing trends research has tracked the share of active US listings carrying a price reduction in the high teens to low twenties percent through recent years, which in a mid-sized metro means dozens of cuts every single day.
The operational problem is that Zillow publishes state, not events. A search page tells you what a home costs right now. It does not tell you that the number was different yesterday. Saved-search emails partially fill that gap for consumers, but they arrive as unstructured HTML, cover one account's filters, and cannot be joined to your CRM, your buyer preferences or your underwriting model.
So the job is mechanical: run a fixed set of searches on a fixed cadence, store every row under a stable key, and compute the delta yourself. Do that and you own an event stream — price_cut, new_listing, back_on_market, delisted — that you can route into Slack, an SMS alert, a lead-scoring job or a daily digest for each agent in the brokerage.
How does this compare to the alternatives?
There is no public Zillow listings API, so a scheduled monitor has three realistic shapes. Consumer saved searches are free but give you email, not data. A hand-rolled scraper gives you data but makes you responsible for pagination state, request pacing and every layout change Zillow ships. A maintained Actor gives you rows and leaves you to write the part that is actually yours — the diff and the alert rules.
| Approach | Cost model | Reliability | Setup time | Maintenance |
|---|---|---|---|---|
| Zillow saved-search emails | Free | Fine, but unparsed HTML per account | 10 minutes | Manual, and no history |
| DIY Python scraper on cron | Your servers, your proxies | Breaks on layout and endpoint changes | 3-7 days | You own every breakage |
| Generic scraping API | Per request, whether or not it parsed | Returns raw HTML; you write the parser | 1-2 days | You own the parser |
| Thirdwatch Zillow Suite Scraper | Transparent pay-per-result | Maintained; challenged sessions retried automatically | 15 minutes | Thirdwatch tracks site changes |
The Zillow Suite Scraper also covers rentals and sold comparables from the same input shape, so the monitor you build for for-sale inventory extends to rental repricing without a second integration.
How to monitor Zillow price drops in 4 steps
How do I set up the token and pick my search areas?
Create an account at apify.com, open Settings, then Integrations, and copy the API token:
export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"Now choose your areas. Zillow stops handing over new results for a single search area after roughly 980 listings, so a monitor built on "Austin, TX" will silently miss most of the market once inventory grows. Shard by ZIP code instead — each ZIP is its own query, and the Actor deduplicates on zpid across queries inside a run, so overlapping areas are safe:
AUSTIN_ZIPS = ["78701", "78702", "78703", "78704", "78745", "78749", "78751", "78757"]Eight to forty ZIP codes per market is typical. Keep the list in config so adding a market is a data change.
How do I pull today's snapshot of active listings?
Call the Actor with searchMode: "forSale" and sortBy: "newest". Sorting by newest matters even when you want the whole set: if a run is cut short, you would rather have lost the stalest listings than the freshest ones. Leave includeDetails off — a monitor needs the price, not the school ratings, and detail mode adds a page load per listing.
import os, requests, pandas as pd
ACTOR = "thirdwatch~zillow-suite-scraper"
TOKEN = os.environ["APIFY_TOKEN"]
resp = requests.post(
f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
params={"token": TOKEN},
json={
"queries": AUSTIN_ZIPS,
"searchMode": "forSale",
"sortBy": "newest",
"maxResults": 400,
"minPrice": 250000,
"maxPrice": 1200000,
"minBeds": 2,
"homeTypes": ["houses", "townhomes", "condos"],
"includeDetails": False,
},
timeout=1800,
)
today = pd.DataFrame(resp.json())
print(f"{len(today)} active listings across {today['source_query'].nunique()} ZIP codes")maxResults is per entry in queries, so this run asks for up to 400 listings in each of the eight ZIP codes. source_query is stamped on every row, which is how you attribute a listing back to the ZIP that produced it when areas overlap.
How do I store snapshots so I can compare runs?
The Actor deduplicates within a run, not across runs. Cross-run memory is yours to build, and zpid is the key to build it on — it is Zillow's own stable property identifier and it survives price changes, status changes and relisting.
Keep two things: a listings table holding the current state per zpid, and an append-only snapshots table holding one row per zpid per run. The second is what makes "when did this drop" answerable three months later.
import sqlite3
from datetime import datetime, timezone
WATCHED = [
"zpid", "property_url", "address", "zip_code", "listing_status",
"price", "price_change", "price_change_date", "price_reduction",
"days_on_zillow", "listed_date", "bedrooms", "bathrooms",
"living_area_sqft", "price_per_sqft", "zestimate", "source_query",
]
run_at = datetime.now(timezone.utc).isoformat()
snapshot = today[[c for c in WATCHED if c in today.columns]].copy()
snapshot["run_at"] = run_at
snapshot["zpid"] = snapshot["zpid"].astype(str)
con = sqlite3.connect("zillow_monitor.db")
snapshot.to_sql("snapshots", con, if_exists="append", index=False)Store price as a number, never as a display string. The Actor already returns numeric price alongside the cosmetic price_formatted, so a diff is arithmetic rather than string parsing.
How do I diff yesterday against today to find price cuts and new listings?
Load the previous run's snapshot, join on zpid, and classify. Three events cover almost every alert anyone actually wants: a listing you have never seen, a price that moved down, and a listing that vanished from the results.
prev = pd.read_sql(
"SELECT * FROM snapshots WHERE run_at = (SELECT MAX(run_at) FROM snapshots "
"WHERE run_at < ?)", con, params=[run_at],
)
merged = snapshot.merge(prev, on="zpid", how="outer", suffixes=("", "_prev"), indicator=True)
new_listings = merged[merged["_merge"] == "left_only"]
gone = merged[merged["_merge"] == "right_only"]
both = merged[merged["_merge"] == "both"].copy()
both["delta"] = both["price"] - both["price_prev"]
price_cuts = both[both["delta"] < -1000]
price_hikes = both[both["delta"] > 1000]
for _, row in price_cuts.sort_values("delta").iterrows():
pct = row["delta"] / row["price_prev"] * 100
print(f"CUT {row['address']} ${row['price_prev']:,.0f} -> ${row['price']:,.0f} "
f"({pct:+.1f}%) {row['days_on_zillow']:.0f} days on Zillow {row['property_url']}")Two refinements are worth adding before you send anything to a human. First, cross-check against Zillow's own change fields: price_change is a signed number, price_change_date is an ISO 8601 timestamp, and price_reduction is the display string Zillow shows on the card, such as "$25,000 (Jul 23)". When your computed delta and price_change agree, confidence is high; when only price_change is populated, you are seeing a cut that happened before your first snapshot.
Second, do not treat a missing zpid as a delisting on the first run it disappears. A challenged page or a search that finished short of maxResults produces the same symptom. Require two consecutive absences:
gone_twice = gone[gone["zpid"].isin(previously_missing_zpids)]
new_confirmed = new_listings[new_listings["days_on_zillow"] <= 3]Filtering new listings on days_on_zillow keeps a recovered-from-a-gap listing out of your "just listed" alert.
How do I schedule the run so it happens without me?
Create an Apify schedule that fires the Actor with a fixed input every morning, then run your diff job against the newest dataset:
curl -X POST "https://api.apify.com/v2/schedules?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "zillow-austin-daily",
"cronExpression": "0 7 * * *",
"timezone": "America/Chicago",
"isEnabled": true,
"actions": [{
"type": "RUN_ACTOR",
"actorId": "thirdwatch~zillow-suite-scraper",
"runInput": {
"queries": ["78701", "78702", "78703", "78704", "78745", "78749", "78751", "78757"],
"searchMode": "forSale",
"sortBy": "newest",
"maxResults": 400,
"includeDetails": false
}
}]
}'Run each market in its own schedule at its own local time. That keeps the run window aligned with when agents actually update listings, so a slow market never delays alerts for a fast one.
Sample output
Two rows from a morning run, trimmed to the fields a monitor reads. The first is a listing that just took a cut; the second is fresh inventory from the same ZIP code.
[
{
"zpid": "29453277",
"property_url": "https://www.zillow.com/homedetails/1204-Kinney-Ave-Austin-TX-78704/29453277_zpid/",
"source_query": "78704",
"search_mode": "forSale",
"listing_status": "FOR_SALE",
"status_text": "House for sale",
"address": "1204 Kinney Ave, Austin, TX 78704",
"zip_code": "78704",
"price": 875000,
"price_formatted": "$875,000",
"currency": "USD",
"price_change": -25000,
"price_change_date": "2026-07-23T00:00:00+00:00",
"price_reduction": "$25,000 (Jul 23)",
"days_on_zillow": 61,
"bedrooms": 3,
"bathrooms": 2,
"living_area_sqft": 1642,
"price_per_sqft": 532.88,
"zestimate": 861400,
"scraped_at": "2026-07-28T12:04:11+00:00"
},
{
"zpid": "29471902",
"property_url": "https://www.zillow.com/homedetails/2107-Ford-St-Austin-TX-78704/29471902_zpid/",
"source_query": "78704",
"listing_status": "FOR_SALE",
"address": "2107 Ford St, Austin, TX 78704",
"price": 649000,
"price_change": null,
"price_reduction": null,
"days_on_zillow": 1,
"listed_date": "2026-07-27T00:00:00+00:00",
"bedrooms": 2,
"bathrooms": 1,
"living_area_sqft": 1080,
"price_per_sqft": 600.93,
"scraped_at": "2026-07-28T12:04:12+00:00"
}
]zpid is the join key across runs. price is numeric, so deltas are arithmetic. price_change, price_change_date and price_reduction are populated only when Zillow is currently advertising a change — the second row is null on all three because it has never moved. days_on_zillow and listed_date separate genuinely new inventory from listings that reappeared after a gap.
Common pitfalls
Deduplication is per run, not across runs. Nothing in the dataset remembers yesterday. Skip your own zpid-keyed store and every run looks like a brand new market.
Roughly 980 listings per search area is a hard ceiling. Zillow serves 41 listings per page and stops paginating at about 24 pages regardless of how many homes match. A whole-city query will report tens of thousands of matches and hand over the first ~980, and your monitor will report phantom delistings for everything past the cut. Shard into ZIP codes or neighbourhoods; the Actor logs a warning when it hits the ceiling.
Some sessions get challenged. Zillow rejects a minority of sessions outright. Retries on fresh sessions recover almost all of them, but an aggressive run can still finish short of maxResults on a query. That is exactly why delisting alerts should require two consecutive absences.
Not every field exists on every listing. New construction, agent-withheld and off-market listings routinely omit Zestimate, lot size or bathroom counts. Missing values come back as null rather than a guess, so guard your arithmetic.
Detail mode is the wrong tool here. includeDetails: true costs an extra page load per listing. Run it on the handful of homes that fired an alert, capped with maxDetailPages — not on the whole daily sweep.
Related use cases
- Build a Zillow API alternative for a property app — the ingestion architecture behind a metro-wide property table
- Pull Zillow sold comps for CMA and valuation reports — the same input shape in
soldmode - Build real estate agent lead lists from Zillow — turn active listings into contactable listing agents
- Screen rental yield with Zillow Rent Zestimate — rank inventory by rent divided by price
- Build a Craigslist rental and housing price monitor — the peer-to-peer rental equivalent
- All Thirdwatch use-case guides
Frequently asked questions
How often should I run a Zillow price-drop monitor?
Once a day is the right default. Listing agents push price changes to the MLS in business hours and Zillow reflects them within a day, so a single early-morning run per market catches almost every cut without paying for redundant pulls.
Does the actor remember what it saw yesterday?
No. Deduplication happens inside a single run only. Persist every row keyed on zpid in your own database or key-value store, then compare the newest snapshot against the previous one to produce change events.
Should I diff on price or trust the price_change field?
Use both. Compare your stored price against the new price to catch every movement, then read price_change, price_change_date and price_reduction as corroboration and for the human-readable label you put in the alert.
How do I detect brand-new listings rather than price cuts?
Set sortBy to newest and treat any zpid absent from your store as new. Cross-check days_on_zillow and listed_date so a listing that was merely missing from a challenged page is not announced twice.
Can one run cover an entire metro area?
Not in one query. Zillow stops paginating a search area at roughly 980 listings, so shard the metro into ZIP codes or neighbourhoods and pass them all in queries. Rows are deduplicated on zpid within the run.
Related
100 free credits, no credit card.
About 30 real searches. Add the MCP to Claude or Cursor in two minutes.