Build a project

Build a Zillow Price-Change Alert Bot for Telegram

Check a list of Zillow homes on a schedule, compare each new collection with the last one you stored, and send price or status changes to a Telegram chat without duplicate alerts.

9 min read

What this alert bot does, and what it does not

You have a short list of homes you care about: listings you are tracking for a client, rentals you might apply for, or properties you plan to make an offer on. You want a message in Telegram when one of them changes price or status. This guide builds that as a small Python program you run on a schedule.

Each run looks up every home on your list, compares the new observation with the previous one stored in a local SQLite file, and sends one Telegram message per change. It remembers which alerts it has already sent, so a rerun after a crash or a repeated cache result does not message you twice.

It is a polling job, not a push feed. The bot learns about a change only when it next runs and the property is collected again, so an alert can arrive well after the listing changed. If you need a change to reach you within a set time, this design does not provide that, and neither does a faster schedule alone.

Compare collections, not requests

Every successful result carries meta.fetched_at, the time the property was collected from its public Zillow page. Repeat lookups of the same home within 24 hours come from our cache and are free. So two runs close together can return the same collection. The bot keys every stored observation by the property and its fetched_at, and skips a result it has already seen instead of treating it as new information.

A property is identified by its zpid. An apartment-building link has no zpid of its own, so the bot falls back to detailUrl, which the API documents as the stable identifier for a building. Do not key the history on the link you typed: a shortened or reformatted link would split one home into two histories.

The comparison is deliberately narrow. A status change, such as FOR_SALE to PENDING, produces a status alert. A price change produces a price alert only when the status is unchanged, because a move between a sale price and a monthly rent is not a price cut. A missing price is not treated as zero: if either observation has no price, there is no price alert.

Previous observationNew observationAlert
FOR_SALE at one priceFOR_SALE at a different pricePrice change
FOR_SALEPENDINGStatus change
Same fetched_at as storedSame fetched_atNone: no new collection
Price shownPrice missingNone: absence is not a price
No stored observationFirst observationNone: this becomes the baseline

Create the bot and find your chat ID

Talk to BotFather in Telegram to create a bot and receive its token. Send your new bot any message, then call the Bot API getUpdates method once to read the chat ID of that conversation. The Bot API takes requests of the form api.telegram.org/bot followed by your token and the method name, so treat the token like a password.

Store three values as environment variables on the machine that runs the job: PROPERTIESAPI_KEY, TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID. Keep all three out of the script and out of version control. The program sends plain text with link previews turned off, which keeps messages compact and avoids parsing surprises from addresses that contain punctuation.

Find the chat ID after messaging your bot
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates" | python3 -m json.tool
# Look for result[].message.chat.id and export it:
export TELEGRAM_CHAT_ID="<the id you found>"

The complete program

Save the program as price_alerts.py and your list as watchlist.txt, one Zillow link per line. It needs Python and the requests package. It sends the links to the batch endpoint, up to 50 links per request, then processes each result on its own: one failed item does not stop the others.

Two options make it safe to try before you connect anything. Use --dry-run to print alerts instead of sending them, and --from-file to process a saved batch response instead of calling the API, which lets you test the comparison logic against fixtures without spending lookups.

price_alerts.py
"""Check a list of Zillow links and send new price or status changes to Telegram.

Run it on a schedule, for example once a day from cron. Requires Python 3.10+ and requests.
Environment: PROPERTIESAPI_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID.
"""
import argparse
import json
import os
import sqlite3
import sys
from pathlib import Path

import requests

BATCH_URL = "https://api.propertiesapi.com/v1/zillow/batch"
TELEGRAM_URL = "https://api.telegram.org/bot{token}/sendMessage"

SCHEMA = """
CREATE TABLE IF NOT EXISTS observations (
  property_key TEXT NOT NULL,
  fetched_at   TEXT NOT NULL,
  price        REAL,
  home_status  TEXT,
  currency     TEXT,
  address      TEXT,
  detail_url   TEXT,
  PRIMARY KEY (property_key, fetched_at)
);
CREATE TABLE IF NOT EXISTS sent_alerts (
  alert_key TEXT PRIMARY KEY,
  sent_at   TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""


def load_watchlist(path):
    lines = Path(path).read_text(encoding="utf-8").splitlines()
    links = [line.strip() for line in lines if line.strip() and not line.startswith("#")]
    if not links:
        raise ValueError("The watchlist is empty")
    return list(dict.fromkeys(links))


def fetch(links, api_key):
    results = []
    for start in range(0, len(links), 50):
        response = requests.post(BATCH_URL, json={"items": links[start:start + 50]},
                                 headers={"x-api-key": api_key}, timeout=(10, 330))
        body = response.json()
        if response.status_code != 200:
            code = (body.get("error") or {}).get("code", response.status_code)
            raise RuntimeError(f"Batch request refused: {code}")
        results.extend(body["data"])
    return results


def observation(data, meta):
    return {"property_key": data.get("zpid") or data.get("detailUrl"),
            "fetched_at": meta.get("fetched_at"), "price": data.get("price"),
            "home_status": data.get("homeStatus"), "currency": data.get("currency"),
            "address": data.get("address"), "detail_url": data.get("detailUrl")}


def changes(previous, current):
    found = []
    if previous["home_status"] and current["home_status"] \
            and previous["home_status"] != current["home_status"]:
        found.append(("status", previous["home_status"], current["home_status"]))
    elif previous["price"] is not None and current["price"] is not None \
            and previous["price"] != current["price"]:
        found.append(("price", previous["price"], current["price"]))
    return found


def show(value):
    return f"{value:,.0f}" if isinstance(value, (int, float)) else str(value)


def message(obs, field, old, new):
    unit = f" {obs['currency']}" if field == "price" and obs["currency"] else ""
    return (f"{field.title()} change: {obs['address']}\n{show(old)}{unit} -> {show(new)}{unit}\n"
            f"Collected {obs['fetched_at']}\n{obs['detail_url']}")


def send(text, dry_run):
    if dry_run:
        print(text, end="\n\n")
        return
    url = TELEGRAM_URL.format(token=os.environ["TELEGRAM_BOT_TOKEN"])
    response = requests.post(url, timeout=30, json={
        "chat_id": os.environ["TELEGRAM_CHAT_ID"], "text": text,
        "link_preview_options": {"is_disabled": True}})
    if not response.json().get("ok"):
        raise RuntimeError(f"Telegram refused the message: {response.text[:200]}")


def process(results, db, dry_run):
    sent = failed = unchanged = 0
    for item in results:
        if not item.get("ok"):
            failed += 1
            print(f"item {item['index']}: {item['error']['code']}", file=sys.stderr)
            continue
        obs = observation(item["data"], item["meta"])
        if not obs["property_key"] or not obs["fetched_at"]:
            failed += 1
            continue
        seen = db.execute("SELECT 1 FROM observations WHERE property_key = ? AND fetched_at = ?",
                          (obs["property_key"], obs["fetched_at"])).fetchone()
        if seen:
            unchanged += 1  # same collection as last time (for example, a cache hit)
            continue
        previous = db.execute(
            "SELECT * FROM observations WHERE property_key = ? AND fetched_at < ? "
            "ORDER BY fetched_at DESC LIMIT 1", (obs["property_key"], obs["fetched_at"])).fetchone()
        with db:
            for field, old, new in (changes(dict(previous), obs) if previous else []):
                alert_key = f"{obs['property_key']}|{field}|{old}|{new}|{obs['fetched_at']}"
                if db.execute("SELECT 1 FROM sent_alerts WHERE alert_key = ?", (alert_key,)).fetchone():
                    continue
                send(message(obs, field, old, new), dry_run)
                db.execute("INSERT INTO sent_alerts (alert_key) VALUES (?)", (alert_key,))
                sent += 1
            db.execute("INSERT INTO observations VALUES (:property_key, :fetched_at, :price, "
                       ":home_status, :currency, :address, :detail_url)", obs)
    return sent, failed, unchanged


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("watchlist", help="Text file with one Zillow link per line")
    parser.add_argument("--db", default="price_alerts.sqlite3")
    parser.add_argument("--from-file", help="Use a saved batch response instead of the API")
    parser.add_argument("--dry-run", action="store_true", help="Print alerts instead of sending")
    args = parser.parse_args()
    db = sqlite3.connect(args.db)
    db.row_factory = sqlite3.Row
    db.executescript(SCHEMA)
    if args.from_file:
        results = json.loads(Path(args.from_file).read_text(encoding="utf-8"))["data"]
    else:
        results = fetch(load_watchlist(args.watchlist), os.environ["PROPERTIESAPI_KEY"])
    sent, failed, unchanged = process(results, db, args.dry_run)
    print(f"alerts sent: {sent}, failed items: {failed}, no new collection: {unchanged}")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())

Schedule it and choose a sensible cadence

Run the program from cron, a systemd timer or your scheduler of choice. Once a day suits most research lists. Running more often mainly returns cached collections: repeat lookups of the same home within 24 hours come from our cache and are free, and the bot recognizes them as nothing new.

Each live lookup is billed like any other call. Zillow links and addresses cost the same: 1 credit per successful lookup. Failed items cost nothing. Size the list to your plan before you schedule it, and read the exit code: the program exits non-zero when any item failed so your scheduler can surface it.

Example crontab entry (daily)
# m h dom mon dow
17 7 * * * cd /opt/price-alerts && /usr/bin/python3 price_alerts.py watchlist.txt >> alerts.log 2>&1

Handle failures without losing or repeating alerts

Items that fail are written to standard error with their error code and leave the stored history untouched, so the next successful run compares against the last good observation. A whole-batch refusal, such as an invalid key or exhausted credits, stops the run before any alert is sent.

Alert state and observations are written in the same SQLite transaction. If Telegram rejects a message, the transaction rolls back and the change is detected again on the next run. The alert key combines the property, the field, the old and new values and the collection time, so the same change is never sent twice once it is recorded. In the rare case where Telegram accepts a message but the database write then fails, you may see that alert again: delivery is at least once, not exactly once.

  • Review not_found and address_ambiguous items by hand; they usually mean the link or address needs fixing.
  • Keep the SQLite file on persistent storage. Deleting it resets every baseline.
  • Read data.calculated.last_price_change if you want the listing's own latest price event in the message.

When to use hosted watchlists instead

If you would rather not run a scheduler, PropertiesAPI watchlists check watched homes about once a day and can post signed webhooks for price, status, Zestimate, rent Zestimate and photo changes. You could point a small webhook receiver at Telegram instead of running this script. The self-hosted version here is still useful when you want full control of the comparison rules, the stored history and the message format.

Validation scope: the comparison, deduplication and failure paths were exercised against synthetic batch responses and a local mock of the batch and Telegram endpoints. No live properties were collected for this article, and no claim about how quickly a listing change appears is implied.

Sources and documentation

Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.

Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.