This guide builds a small Zillow price-drop monitor in one Python file. It requests the first page of a saved for-sale search, stores each price in SQLite and prints an alert when the same ZPID returns with a lower price.

The monitor is deliberately small. It runs one request at a time and treats a missing property only as absent from the current first page. It does not prove that a home was sold or removed from Zillow.

What you need

You need Python 3.10 or newer and access to the Real Estate Zillow.com API on RapidAPI. Start with one location and one page. That makes the first version easier to understand and keeps request use predictable.

The code uses the provider's GET /v1/search/sale route. It sends location_or_rid, page, beds_min, price_max and sort. API contracts can change, so compare these names with the RapidAPI playground before running the monitor.

Step 1: create the project

Open PowerShell and run these commands one line at a time:

mkdir zillow-price-monitor
cd zillow-price-monitor
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install requests

Python already includes SQLite, so you do not install a separate database package. The standard sqlite3 module creates a local file when the monitor first runs.

Step 2: set the API key

Keep the RapidAPI key outside the Python file. These PowerShell commands ask for the key without displaying it and place it in the current terminal session:

$secureKey = Read-Host "RapidAPI key" -AsSecureString
$env:RAPIDAPI_KEY = [System.Net.NetworkCredential]::new("", $secureKey).Password
$env:ZILLOW_LOCATION_OR_RID = "12700"

12700 is the example region used by the provider for Miami. Replace it with the location or region ID you have checked for your search. Closing this PowerShell window removes the environment variables.

Step 3: add the complete Python monitor

Create zillow_monitor.py and paste in this code:

from __future__ import annotations

import hashlib
import os
import re
import sqlite3
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path
from typing import Any

import requests


API_URL = "https://real-estate-zillow-com.p.rapidapi.com/v1/search/sale"
API_HOST = "real-estate-zillow-com.p.rapidapi.com"
DATABASE_PATH = Path("zillow_monitor.sqlite3")


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


def price_to_cents(value: Any) -> int | None:
    if value is None or isinstance(value, bool):
        return None

    cleaned = re.sub(r"[^\d.\-]", "", str(value).strip())
    if not cleaned:
        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_listing_list(node: Any) -> list[dict[str, Any]]:
    if isinstance(node, list):
        dictionaries = [item for item in node if isinstance(item, dict)]
        if any(item.get("zpid") for item in dictionaries):
            return dictionaries

        for item in dictionaries:
            found = find_listing_list(item)
            if found:
                return found

    if isinstance(node, dict):
        for value in node.values():
            found = find_listing_list(value)
            if found:
                return found

    return []


def fetch_listings() -> tuple[list[dict[str, Any]], str]:
    api_key = os.environ.get("RAPIDAPI_KEY")
    if not api_key:
        raise RuntimeError("RAPIDAPI_KEY is not set")

    headers = {
        "X-RapidAPI-Key": api_key,
        "X-RapidAPI-Host": API_HOST,
    }
    params = {
        "location_or_rid": os.getenv("ZILLOW_LOCATION_OR_RID", "12700"),
        "page": 1,
        "beds_min": 2,
        "price_max": 500000,
        "sort": "newest",
    }

    response = requests.get(
        API_URL,
        headers=headers,
        params=params,
        timeout=(5, 30),
    )
    response.raise_for_status()
    payload = response.json()

    if (
        not isinstance(payload, dict)
        or payload.get("status") != 200
        or not isinstance(payload.get("data"), dict)
    ):
        raise RuntimeError("The API returned an unexpected JSON wrapper")

    observed_at = utc_now()
    by_zpid: dict[str, dict[str, Any]] = {}

    for item in find_listing_list(payload["data"]):
        zpid = str(item.get("zpid") or "").strip()
        if not zpid:
            continue

        by_zpid[zpid] = {
            "zpid": zpid,
            "price_cents": price_to_cents(item.get("price")),
            "detail_url": item.get("detailUrl"),
            "observed_at_utc": observed_at,
        }

    if not by_zpid:
        raise RuntimeError(
            "No listings were found. Stop here instead of marking every saved row missing."
        )

    return list(by_zpid.values()), observed_at


def open_database(path: Path = DATABASE_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,
            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 alert_fingerprint(zpid: str, old_price: int, new_price: int) -> str:
    text = f"{zpid}:{old_price}:{new_price}"
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def save_snapshot(
    connection: sqlite3.Connection,
    listings: list[dict[str, Any]],
    observed_at: str,
) -> tuple[dict[str, int], list[dict[str, Any]]]:
    previous = {
        row["zpid"]: row
        for row in connection.execute("SELECT * FROM listing_state").fetchall()
    }
    current_ids = {listing["zpid"] for listing in listings}
    counts = {"new": 0, "changed": 0, "unchanged": 0, "missing": 0}
    price_drops: list[dict[str, Any]] = []

    with connection:
        for listing in listings:
            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["detail_url"] != listing["detail_url"]
                )
                counts["changed" if changed else "unchanged"] += 1

                old_price = old["price_cents"]
                new_price = listing["price_cents"]
                if (
                    not old["is_missing"]
                    and old_price is not None
                    and new_price is not None
                    and new_price < old_price
                ):
                    fingerprint = alert_fingerprint(
                        listing["zpid"], old_price, new_price
                    )
                    inserted = connection.execute(
                        """
                        INSERT OR IGNORE INTO alert_log
                            (fingerprint, zpid, old_price_cents,
                             new_price_cents, created_at_utc)
                        VALUES (?, ?, ?, ?, ?)
                        """,
                        (
                            fingerprint,
                            listing["zpid"],
                            old_price,
                            new_price,
                            observed_at,
                        ),
                    ).rowcount

                    if inserted:
                        price_drops.append(
                            {
                                "zpid": listing["zpid"],
                                "old_price_cents": old_price,
                                "new_price_cents": new_price,
                                "detail_url": listing["detail_url"],
                            }
                        )

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

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

    return counts, price_drops


def dollars(cents: int) -> str:
    return f"${cents / 100:,.2f}"


def main() -> None:
    try:
        listings, observed_at = fetch_listings()
    except (requests.RequestException, ValueError, RuntimeError) as error:
        raise SystemExit(f"Monitor stopped without changing SQLite: {error}")

    connection = open_database()
    try:
        counts, price_drops = save_snapshot(
            connection, listings, observed_at
        )
    finally:
        connection.close()

    for drop in price_drops:
        print(
            f"PRICE DROP {drop['zpid']}: "
            f"{dollars(drop['old_price_cents'])} -> "
            f"{dollars(drop['new_price_cents'])} | "
            f"{drop['detail_url'] or 'URL unavailable'}"
        )

    print(f"{observed_at} | {counts} | alerts={len(price_drops)}")


if __name__ == "__main__":
    main()

Step 4: run it twice

Run the script from the activated virtual environment:

python .\zillow_monitor.py

The first successful run creates zillow_monitor.sqlite3. Every listing is new, so there is no price-drop alert yet. Run the script again later with the same search settings. A normal summary looks like this:

2026-09-22T08:00:00+00:00 | {'new': 0, 'changed': 1, 'unchanged': 18, 'missing': 1} | alerts=1

This output is illustrative. The count depends on the live search response.

What SQLite stores

listing_state keeps the latest row for each ZPID. A ZPID is stored as text because it is an identifier, not a number you calculate with. The price is stored as integer cents, so the comparison does not use binary floating-point values.

alert_log keeps a SHA-256 fingerprint made from ZPID, old price and new price. Its primary key prevents the same price transition from creating the same alert twice.

The exact price-drop rule

The monitor prints an alert only when the previous and current prices both exist, the property was present in the previous snapshot and the new price is lower. A missing or unreadable price becomes None. It never becomes zero.

A property that returns after being marked missing does not create an immediate price-drop alert. The code first establishes a fresh comparison point. This avoids comparing a new observation with an old price from an uncertain gap.

What missing means in this version

This beginner version requests only page 1. Therefore, is_missing=1 means the ZPID was not on the current first page. The listing may still exist on another page or under different filters.

The code refuses to update SQLite when the response has no recognizable listing objects. That protects the saved state from a broken response, an unexpected JSON shape or an empty challenge page.

Schedule the monitor

After manual runs work, use Windows Task Scheduler or cron to call the script. Keep the API key in the scheduler's protected environment. Do not put it in the Python file, command arguments or logs.

One page every six hours means four API requests per day, or about 120 requests in a 30-day month. Check the current plan allowance in RapidAPI before choosing an interval. If a request fails, let the next scheduled run try again instead of starting a fast retry loop.

Common errors

RAPIDAPI_KEY is not set

Set the key in the same terminal window that runs Python. Environment variables set in another window are not copied into the current process.

The request returns an HTTP error

Check the subscription, host name, endpoint and parameters in the RapidAPI playground. Do not print the request headers while debugging because they contain the API key.

No listings were found

Inspect a sanitized copy of the JSON response from your own subscription. The provider documents fields such as zpid, price and detailUrl, but the enclosing JSON can evolve. Update find_listing_list() only after you see the real response.

SQLite says the database is locked

Make sure only one scheduled copy runs at a time. This tutorial is designed for one local process, not parallel workers.

When you need more than page 1

Add pagination only after the one-page version works. Fetch every configured page before opening SQLite. Mark records missing only when the complete fetch succeeds. The companion Zillow fields and endpoints guide explains which route and parameters belong to each search type.

Review the Zillow API fields and plans