Build a Zillow price-drop monitor with an API

Build a Zillow price-drop monitor with an API

Turn periodic Zillow API search results into a compact price-change monitor. This guide uses Python and SQLite to compare UTC snapshots, distinguish missing listings from real price drops, suppress duplicate alerts, and keep delivery interchangeable.

Key Takeaways

  • Use the verified GET /zillow/v1/search contract and documented zpid, price, statusType, and detailUrl fields.
  • Normalize nullable and formatted prices to integer cents, then compare complete UTC snapshots in SQLite.
  • Classify new, changed, removed, and unchanged listings without treating disappearance as a price drop.
  • Suppress duplicate alerts with durable transition fingerprints and run the monitor on a bounded schedule with timeout, retry, backoff, and request-budget controls.

What this monitor does

A saved property search becomes much more useful when it can answer a new question: which listing actually became cheaper since the last check? This guide builds that small monitoring loop. A scheduled Python process requests the same Zillow search, normalizes every listing into a stable local shape, compares it with a previous SQLite snapshot, and emits an event only when the observed price moves down.

The scope is intentionally modest. It is a local monitor for one search profile, not a production alerting platform. It does not predict value, verify whether a listing is still available, or replace due diligence. Search membership can change because a home sold, a listing was removed, filters changed, or pagination was incomplete. For that reason, disappearance is recorded as missing, never as a price drop.

Confirmed product behavior: the Real Estate Zillow.com API exposes a live search endpoint and documented listing fields through RapidAPI. Suggested application architecture: SQLite state, scheduled execution, comparison rules, alert fingerprints, and notification adapters are code in your application. They are not scheduler, webhook, or notification features supplied by LetsScrape.

Saved search
Scheduled API request
Normalize listings
Compare snapshot
Price-drop event
Notification adapter
The API provides the current observations. Your application owns persistence, comparison, scheduling, and delivery.

Use the verified search contract

The current provider documentation specifies GET /zillow/v1/search on real-estate-zillow-com.p.rapidapi.com. Requests use the standard X-RapidAPI-Key and X-RapidAPI-Host headers. The example keeps the key in RAPIDAPI_KEY, never in source code, logs, or a URL.

The saved search uses the documented rid, type, page, beds_min, price_max, and sort parameters. Region 12700 is the provider's documented Miami example; set ZILLOW_REGION_ID to the reviewed region ID for your own market. Keep the same filters between runs. Changing them changes the population and makes snapshot comparisons misleading.

GET /zillow/v1/search?rid=12700&type=sale&page=1&beds_min=2&price_max=500000&sort=newest
X-RapidAPI-Host: real-estate-zillow-com.p.rapidapi.com
X-RapidAPI-Key: supplied from RAPIDAPI_KEY

The response wrapper documents a numeric status and a data object. Listing objects expose zpid, price, statusType, and detailUrl. The provider overview does not promise a name for the nested listing collection, so the helper below finds a list by the documented invariant that its objects carry a non-empty zpid. If a subscribed fixture confirms a stable path for your plan, replace the recursive search with direct indexing and test that path.

Keep the source contract separate from local state

API fieldLocal fieldReason
zpidzpid text keyStable property identity across observations.
priceprice_centsNullable integer comparison after display formatting is removed.
statusTypestatus_typePreserves a documented listing state without interpreting it as price.
detailUrldetail_urlKeeps the documented source link with an alert.
Observation timeobserved_at_utcAdded by the monitor in UTC; it is not claimed as an API response field.

This boundary matters when the upstream response evolves. The monitor reads only documented fields and treats every one except ZPID as nullable. Application-only values—observation time, missing state, and alert fingerprint—never get presented as fields returned by Zillow or LetsScrape. That distinction also makes fixtures easier to understand: the source fixture can remain a sanitized API response, while SQLite assertions cover the application model.

Do not use the URL, street address, or result position as identity. URLs can be reformatted, addresses can vary in punctuation, and ranking changes from run to run. A ZPID is the dedicated identity field. Similarly, compare the observed list price rather than Zestimate or rent Zestimate; those are different measures with different meanings even when they appear beside the listing price.

Design the snapshot before writing alerts

Each local row represents the latest observation of one property. zpid is Zillow's stable property identifier and is stored as text because identifiers are not values for arithmetic. price_cents is an integer, avoiding binary floating-point comparison. The status, direct listing URL, UTC observation time, and missing flag provide enough context to classify a change without pretending that absence means a discount.

Prices can be numeric, null, or formatted strings such as $450,000. Normalization strips display characters, parses with Decimal, rejects negative and non-finite values, and retains null when there is no usable price. A missing price must not become zero: that would create a dramatic but false price drop.

A second table stores the fingerprint of every emitted transition: ZPID, old price, and new price. The insert uses a unique primary key, so a replay of the same transition cannot create a duplicate alert. For a larger system, use an outbox with delivery status and retry metadata. The compact version commits the event before calling the adapter, which favors duplicate suppression but cannot automatically redeliver after an adapter failure.

Complete runnable Python monitor

Use Python 3.10 or newer. Create a virtual environment and install the only external dependency:

python -m venv .venv
# macOS or Linux: source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install requests

Save the following as zillow_monitor.py. It requests a bounded number of pages, treats an exhausted page limit as an incomplete snapshot, and therefore refuses to mark unseen listings missing unless pagination reached a reliable stop condition.

from __future__ import annotations

import hashlib
import os
import random
import re
import sqlite3
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any, Callable

import requests

API_URL = "https://real-estate-zillow-com.p.rapidapi.com/zillow/v1/search"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
DB_PATH = Path(os.getenv("ZILLOW_MONITOR_DB", "zillow_monitor.sqlite3"))
MAX_ATTEMPTS = 4
MAX_PAGES = int(os.getenv("ZILLOW_MAX_PAGES", "3"))


@dataclass(frozen=True)
class Listing:
    zpid: str
    price_cents: int | None
    status_type: str | None
    detail_url: str | None
    observed_at_utc: str


@dataclass(frozen=True)
class PriceDrop:
    zpid: str
    old_price_cents: int
    new_price_cents: int
    detail_url: str | None
    observed_at_utc: str


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def normalize_price(value: Any) -> int | None:
    """Convert numbers or values such as '$450,000' to integer cents."""
    if value is None or isinstance(value, bool):
        return None
    if isinstance(value, (int, float, Decimal)):
        cleaned = str(value)
    elif isinstance(value, str):
        cleaned = re.sub(r"[^\d.\-]", "", value.strip())
        if not cleaned:
            return None
    else:
        return None

    try:
        amount = Decimal(cleaned)
    except InvalidOperation:
        return None
    if not amount.is_finite() or amount < 0:
        return None
    return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))


def find_listings(node: Any) -> list[dict[str, Any]]:
    """Find the documented listing objects without guessing a container name."""
    if isinstance(node, list):
        objects = [item for item in node if isinstance(item, dict)]
        if any(item.get("zpid") for item in objects):
            return objects
        for item in objects:
            found = find_listings(item)
            if found:
                return found
    elif isinstance(node, dict):
        for value in node.values():
            found = find_listings(value)
            if found:
                return found
    return []


def normalize_listings(payload: dict[str, Any], observed_at_utc: str) -> list[Listing]:
    if payload.get("status") != 200 or not isinstance(payload.get("data"), dict):
        raise ValueError("Unexpected Zillow API response wrapper")

    normalized: dict[str, Listing] = {}
    for item in find_listings(payload["data"]):
        if not item.get("zpid"):
            continue
        listing = Listing(
            zpid=str(item["zpid"]),
            price_cents=normalize_price(item.get("price")),
            status_type=item.get("statusType"),
            detail_url=item.get("detailUrl"),
            observed_at_utc=observed_at_utc,
        )
        normalized[listing.zpid] = listing
    return list(normalized.values())


def retry_delay(response: requests.Response | None, attempt: int) -> float:
    if response is not None:
        header = response.headers.get("Retry-After")
        if header and header.isdigit():
            return min(float(header), 60.0)
    return min(2 ** (attempt - 1) + random.random(), 30.0)


def fetch_page(page: int) -> dict[str, Any]:
    api_key = os.environ.get("RAPIDAPI_KEY")
    if not api_key:
        raise RuntimeError("Set RAPIDAPI_KEY before running the monitor")

    headers = {
        "X-RapidAPI-Key": api_key,
        "X-RapidAPI-Host": API_HOST,
    }
    params = {
        "rid": int(os.getenv("ZILLOW_REGION_ID", "12700")),
        "type": "sale",
        "page": page,
        "beds_min": 2,
        "price_max": 500000,
        "sort": "newest",
    }

    with requests.Session() as session:
        for attempt in range(1, MAX_ATTEMPTS + 1):
            response: requests.Response | None = None
            try:
                response = session.get(
                    API_URL,
                    headers=headers,
                    params=params,
                    timeout=(5, 30),
                )
                if response.status_code == 429 or response.status_code >= 500:
                    if attempt == MAX_ATTEMPTS:
                        response.raise_for_status()
                    time.sleep(retry_delay(response, attempt))
                    continue
                response.raise_for_status()
                payload = response.json()
                if not isinstance(payload, dict):
                    raise ValueError("The Zillow API response is not a JSON object")
                return payload
            except (requests.Timeout, requests.ConnectionError):
                if attempt == MAX_ATTEMPTS:
                    raise
                time.sleep(retry_delay(response, attempt))

    raise RuntimeError("Zillow API request exhausted its retry budget")


def fetch_snapshot(observed_at_utc: str) -> tuple[list[Listing], bool]:
    by_zpid: dict[str, Listing] = {}
    for page in range(1, MAX_PAGES + 1):
        page_listings = normalize_listings(fetch_page(page), observed_at_utc)
        if not page_listings:
            return list(by_zpid.values()), True
        before = len(by_zpid)
        for listing in page_listings:
            by_zpid[listing.zpid] = listing
        if len(by_zpid) == before:
            return list(by_zpid.values()), True
    return list(by_zpid.values()), False


def open_database(path: Path = DB_PATH) -> sqlite3.Connection:
    connection = sqlite3.connect(path)
    connection.row_factory = sqlite3.Row
    connection.executescript(
        """
        CREATE TABLE IF NOT EXISTS listing_state (
            zpid TEXT PRIMARY KEY,
            price_cents INTEGER,
            status_type TEXT,
            detail_url TEXT,
            observed_at_utc TEXT NOT NULL,
            is_missing INTEGER NOT NULL DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS alert_log (
            fingerprint TEXT PRIMARY KEY,
            zpid TEXT NOT NULL,
            old_price_cents INTEGER NOT NULL,
            new_price_cents INTEGER NOT NULL,
            created_at_utc TEXT NOT NULL
        );
        """
    )
    return connection


def fingerprint(drop: PriceDrop) -> str:
    transition = f"{drop.zpid}:{drop.old_price_cents}:{drop.new_price_cents}"
    return hashlib.sha256(transition.encode("utf-8")).hexdigest()


def compare_and_store(
    connection: sqlite3.Connection,
    current: list[Listing],
    mark_missing: bool = True,
) -> tuple[dict[str, int], list[PriceDrop]]:
    previous = {
        row["zpid"]: row
        for row in connection.execute("SELECT * FROM listing_state").fetchall()
    }
    counts = {"new": 0, "changed": 0, "removed": 0, "unchanged": 0}
    drops: list[PriceDrop] = []
    current_ids = {listing.zpid for listing in current}

    with connection:
        for listing in current:
            old = previous.get(listing.zpid)
            if old is None:
                counts["new"] += 1
            else:
                changed = (
                    bool(old["is_missing"])
                    or old["price_cents"] != listing.price_cents
                    or old["status_type"] != listing.status_type
                    or old["detail_url"] != listing.detail_url
                )
                counts["changed" if changed else "unchanged"] += 1

                if (
                    old["price_cents"] is not None
                    and listing.price_cents is not None
                    and listing.price_cents < old["price_cents"]
                ):
                    drop = PriceDrop(
                        zpid=listing.zpid,
                        old_price_cents=old["price_cents"],
                        new_price_cents=listing.price_cents,
                        detail_url=listing.detail_url,
                        observed_at_utc=listing.observed_at_utc,
                    )
                    inserted = connection.execute(
                        """
                        INSERT OR IGNORE INTO alert_log
                            (fingerprint, zpid, old_price_cents, new_price_cents, created_at_utc)
                        VALUES (?, ?, ?, ?, ?)
                        """,
                        (
                            fingerprint(drop),
                            drop.zpid,
                            drop.old_price_cents,
                            drop.new_price_cents,
                            drop.observed_at_utc,
                        ),
                    ).rowcount
                    if inserted:
                        drops.append(drop)

            connection.execute(
                """
                INSERT INTO listing_state
                    (zpid, price_cents, status_type, detail_url, observed_at_utc, is_missing)
                VALUES (?, ?, ?, ?, ?, 0)
                ON CONFLICT(zpid) DO UPDATE SET
                    price_cents = excluded.price_cents,
                    status_type = excluded.status_type,
                    detail_url = excluded.detail_url,
                    observed_at_utc = excluded.observed_at_utc,
                    is_missing = 0
                """,
                (
                    listing.zpid,
                    listing.price_cents,
                    listing.status_type,
                    listing.detail_url,
                    listing.observed_at_utc,
                ),
            )

        if mark_missing:
            for zpid, old in previous.items():
                if zpid not in current_ids and not old["is_missing"]:
                    counts["removed"] += 1
                    connection.execute(
                        """
                        UPDATE listing_state
                        SET is_missing = 1, observed_at_utc = ?
                        WHERE zpid = ?
                        """,
                        (utc_now(), zpid),
                    )

    return counts, drops


def console_notify(drop: PriceDrop) -> None:
    old_dollars = drop.old_price_cents / 100
    new_dollars = drop.new_price_cents / 100
    print(
        f"PRICE DROP {drop.zpid}: ${old_dollars:,.2f} -> "
        f"${new_dollars:,.2f} | {drop.detail_url or 'URL unavailable'}"
    )


def run(notify: Callable[[PriceDrop], None] = console_notify) -> None:
    observed_at = utc_now()
    listings, snapshot_complete = fetch_snapshot(observed_at)
    with open_database() as connection:
        counts, drops = compare_and_store(
            connection,
            listings,
            mark_missing=snapshot_complete,
        )
    for drop in drops:
        notify(drop)
    print(
        f"{observed_at} | {counts} | alerts={len(drops)} | "
        f"snapshot_complete={snapshot_complete}"
    )


if __name__ == "__main__":
    run()

How the comparison classifies every listing

On the first successful run, every normalized ZPID is new. No price-drop notification is sent because there is no earlier observation to compare. On later runs, a record is unchanged only when its price, status, URL, and missing flag all match. A price increase, status transition, URL correction, return after disappearance, or any price difference is changed.

A changed row becomes a price-drop event only when both prices are known and the new integer amount is strictly lower than the old one. A null price on either side is merely a data change. This rule keeps formatted, absent, and temporarily unavailable values from masquerading as discounts.

A previously observed ZPID that is absent from a complete current snapshot is removed from the result set and retained with is_missing=1. It does not generate a price alert. The retained row lets the monitor recognize a later reappearance. When the configured page ceiling is reached, snapshot_complete=False and missing classification is skipped, because a partial snapshot cannot prove disappearance.

Re-running the exact same snapshot produces only unchanged records. The stored prices already match, and even a replayed transition cannot pass the unique alert fingerprint. That gives the small system two layers of duplicate suppression without relying on in-memory state.

Run it safely and schedule it

Set the RapidAPI key interactively or through your deployment secret manager. Do not commit a .env file containing the real value. You can also set the region, page budget, and database path without editing code:

# macOS or Linux
read -s RAPIDAPI_KEY
export RAPIDAPI_KEY
export ZILLOW_REGION_ID=12700
export ZILLOW_MAX_PAGES=3
python zillow_monitor.py

# Windows PowerShell
$env:RAPIDAPI_KEY = Read-Host "RapidAPI key" -MaskInput
$env:ZILLOW_REGION_ID = "12700"
$env:ZILLOW_MAX_PAGES = "3"
python .\zillow_monitor.py

The first run creates zillow_monitor.sqlite3 in the working directory. Protect and back up that file if historical continuity matters. Run the command twice with an unchanged API result: the second summary should show unchanged listings and alerts=0.

Scheduling belongs to the host running your application. For example, this cron entry starts the monitor every six hours from its project directory:

15 */6 * * * cd /opt/zillow-monitor && /opt/zillow-monitor/.venv/bin/python zillow_monitor.py >> monitor.log 2>&1

Populate RAPIDAPI_KEY through the scheduler's protected environment rather than writing it into the crontab. On Windows, create an equivalent Task Scheduler action that runs the virtual environment's Python executable with the script path as its argument. Prevent overlapping runs with the scheduler's concurrency setting or a host-level lock.

Swap the notification adapter

console_notify is deliberately safe: it prints only the ZPID, old and new prices, and documented detail URL. It never prints request headers or the API key. The run function accepts any callable with the same PriceDrop input, so an application can supply its own email or webhook adapter without coupling transport code to snapshot comparison.

Keep delivery credentials in a secret manager, add short timeouts, and avoid logging recipient addresses or authorization headers. If delivery must be guaranteed, extend alert_log into an outbox: store pending events transactionally, deliver them in a separate worker, and mark them sent only after acknowledgment. That is an application pattern, not a capability promised by LetsScrape.

Timeouts, retries, and request budget

The request uses separate five-second connection and thirty-second read timeouts. It retries connection failures, timeouts, HTTP 429, and server errors up to four attempts with exponential backoff and jitter. A numeric Retry-After header takes precedence and is capped at sixty seconds. Other 4xx responses fail immediately because invalid parameters, authentication, or subscription access will not improve through rapid retries.

Retries consume request allowance, so budget for worst-case attempts rather than only scheduled runs. With three pages, four runs per day, and no retries, the monitor uses about 360 requests in a 30-day month. The theoretical four-attempt ceiling is 1,440. Set ZILLOW_MAX_PAGES from the search size and plan allowance, alert on sustained 429 responses, and add more delay instead of increasing concurrency.

Only commit a snapshot after every received page has passed HTTP, JSON, wrapper, and normalization checks. The code does this by fetching first and opening the database afterward. A malformed response or exhausted retry budget therefore leaves the last known state intact. Logs should contain your run ID, status code, page number, elapsed time, and summary counts—not complete headers or raw bodies.

Know the monitor's limits

Search results are live observations, not an immutable feed. A home may disappear because of status, ranking, filters, pagination, or upstream presentation changes. Treat removed as “not present in this complete search snapshot,” not proof that a property sold. For higher confidence, require disappearance in multiple runs or verify the ZPID with the separate property endpoint before a business action.

The transition fingerprint intentionally suppresses the same old-to-new price pair forever. That is simple and predictable, but a listing that rises and later repeats the identical drop will not alert twice. A production design can include a monitored episode ID or a time bucket while still enforcing uniqueness. Likewise, one SQLite file is appropriate for a single scheduled process, not many concurrent workers.

Finally, test the integration against a sanitized response fixture from your own subscription. The field vocabulary above is documented, but live inventory and nullable values vary by listing. Contract tests should cover null price, formatted price, empty pages, repeated pages, disappearance, reappearance, a price increase, one price drop, and an unchanged replay.

Build from a real Zillow response

This monitor keeps the durable application logic small while using the API only for what it confirms: current structured search observations. Start with one reviewed region and a conservative schedule, inspect the first snapshots, then tune filters and request frequency around the decisions the alert will support.

Explore the Zillow Real Estate API