Put Your Property Shortlist on an Interactive Map
Map the properties you already chose with Python, GeoJSON and Leaflet. Use returned coordinates, label every popup, keep missing locations visible and the API key on the server.
A map of the properties you chose, not a map search
A table tells you what each property costs. A map tells you how the shortlist is spread out: which homes cluster near the same corridor, which one sits on the far side of a river, and which address was resolved somewhere you did not expect. This guide builds that map with Python and a single HTML file.
The input is a list you already have: Zillow links or full addresses from a spreadsheet, a client email or your own notes. Each property response carries latitude and longitude, so placing a marker needs no separate geocoding service. The project does not discover new homes, browse a neighborhood or show inventory that you did not request. Every marker on the map corresponds to a row you supplied.
The build has three steps. A collector script runs on your machine or server and saves the full responses to a snapshot file. A second script converts that snapshot into GeoJSON and a map page. The browser then opens a static file that contains property data but no API key and makes no API calls.
- collect_snapshot.py looks up the shortlist with the batch endpoint and saves every result, including failures.
- build_map.py writes shortlist.geojson and map.html from that snapshot.
- map.html shows labeled popups and a separate list of rows that could not be placed.
Prepare the shortlist file
Create shortlist.csv with two columns: id and input. The id is your own stable reference, such as a CRM record or a spreadsheet row label. The input is either a zillow.com property link or a one-line US address with a house number, city and state or ZIP code. Include the unit for condos and apartments, because a building address without a unit can describe a different record.
Keep the file small on the first run. The map is only as good as the identity behind each marker, and it is much easier to inspect a handful of popups than to debug a large import.
id,input
home-a,"2640 Steiner St, San Francisco, CA 94115"
home-b,https://www.zillow.com/homedetails/130-Water-St-APT-12D-New-York-NY-10005/2064142765_zpid/
Collect the responses on the server side
The collector sends the inputs to the batch endpoint in groups and stores the complete data and meta objects for each item. Saving the whole response matters here: the map needs latitude, longitude, detailUrl and statusText, and later projects may need fields this guide does not use. Failed items are kept with their error code instead of being dropped.
Run the collector from a terminal or a backend job where PROPERTIESAPI_KEY is set as an environment variable. Never put the key in the HTML file, a JavaScript bundle or a URL. The map page you share later is built only from the saved snapshot. If a group stops with an account or rate-limit response, the remaining rows are marked not_requested so you can see exactly what was not looked up.
"""collect_snapshot.py: look up a supplied list and save every full response.
Usage: python collect_snapshot.py shortlist.csv snapshot.json
shortlist.csv needs id and input columns (input = a Zillow link or a full address).
"""
import csv, json, os, sys
from datetime import datetime, timezone
import requests
BATCH = "https://api.propertiesapi.com/v1/zillow/batch"
CHUNK = 50 # the batch endpoint accepts 1 to 50 items
def load(path):
with open(path, newline="", encoding="utf-8-sig") as f:
rows = [{"id": r["id"].strip(), "input": r["input"].strip()} for r in csv.DictReader(f)]
ids = [r["id"] for r in rows]
if not rows or len(set(ids)) != len(ids) or not all(r["id"] and r["input"] for r in rows):
raise SystemExit("every row needs a unique id and an input")
return rows
def collect(rows):
out = []
with requests.Session() as s:
s.headers["x-api-key"] = os.environ["PROPERTIESAPI_KEY"] # server side only
for start in range(0, len(rows), CHUNK):
chunk = rows[start:start + CHUNK]
r = s.post(BATCH, json={"items": [x["input"] for x in chunk]},
timeout=(10, 330), allow_redirects=False)
if r.status_code != 200: # auth, credits, rate limit: stop and keep the rest visible
print(f"stopped at row {start}: HTTP {r.status_code} {r.text[:300]}", file=sys.stderr)
out += [{**x, "ok": False, "data": None, "meta": None,
"error": {"code": "not_requested"}} for x in rows[start:]]
break
for item in r.json()["data"]:
src = chunk[item["index"]]
out.append({**src, "ok": item["ok"], "data": item.get("data"),
"meta": item.get("meta"), "error": item.get("error")})
return out
if __name__ == "__main__":
rows = collect(load(sys.argv[1]))
snapshot = {"exported_at": datetime.now(timezone.utc).isoformat(), "rows": rows}
with open(sys.argv[2], "w", encoding="utf-8") as f:
json.dump(snapshot, f, indent=2)
failed = [r for r in rows if not r["ok"]]
print(f"saved {len(rows)} rows, {len(failed)} failed")
Get the coordinate order right
The most common mapping bug is swapping latitude and longitude. The property response returns them as two named fields, latitude and longitude, which removes the guesswork at the source. The trap appears when you convert them. The GeoJSON specification orders a position as longitude first, then latitude. Leaflet marker functions take latitude first. The scripts below write GeoJSON in its required order and swap back only at the point where Leaflet needs the opposite.
A quick sanity check catches most mistakes: every property in the contiguous United States has a negative longitude and a positive latitude. If a marker lands in the ocean or on another continent, look at the order before you look at the data.
Treat a missing coordinate as missing. The script checks that both values are real numbers and refuses to substitute zero, a city centroid or a geocoded guess. A home without coordinates goes into a separate list with the reason, so nobody concludes that the shortlist was smaller than it was.
Build the GeoJSON file and the map page
build_map.py reads the snapshot, writes a standard GeoJSON FeatureCollection that any GIS tool can open, and fills an HTML template with the same data. The popup fields are chosen to answer the questions a reviewer asks first: what did I ask for, what came back, what does the price represent, and when was it collected.
The price label depends on homeStatus. A home listed for sale shows an asking price, a rental shows monthly asking rent, and a recently sold home shows a sale price when one is disclosed. Off-market homes usually have no price at all, so the popup says Unavailable instead of showing a number that could be mistaken for a value. The Zestimate is labeled as Zillow's estimate and kept on its own line.
Only detailUrl values that point to zillow.com become links, and they open with noopener. Everything else in the popup is plain text.
"""build_map.py: turn snapshot.json into shortlist.geojson and map.html (no API key inside)."""
import json, sys
from pathlib import Path
ASKING = {"FOR_SALE", "PENDING", "COMING_SOON", "FORECLOSURE"}
def price_label(status):
if status in ASKING: return "Asking price"
if status == "FOR_RENT": return "Monthly asking rent"
if status == "RECENTLY_SOLD": return "Sale price"
return "Price"
def is_number(v):
return isinstance(v, (int, float)) and not isinstance(v, bool)
def split(snapshot):
features, missing = [], []
for row in snapshot["rows"]:
d, meta = row.get("data") or {}, row.get("meta") or {}
lat, lon = d.get("latitude"), d.get("longitude")
if not row["ok"] or not (is_number(lat) and is_number(lon)):
reason = (row.get("error") or {}).get("code") or "no coordinates returned"
missing.append({"id": row["id"], "input": row["input"], "reason": reason})
continue
url = d.get("detailUrl") or ""
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]}, # GeoJSON order: lon, lat
"properties": {
"id": row["id"], "input": row["input"],
"address": d.get("address"), "unit": d.get("unit"),
"status": d.get("statusText") or d.get("homeStatus"),
"priceLabel": price_label(d.get("homeStatus")),
"price": d.get("price"), "currency": d.get("currency"),
"zestimate": d.get("zestimate"),
"detailUrl": url if url.startswith("https://www.zillow.com/") else None,
"fetchedAt": meta.get("fetched_at"), "warning": meta.get("warning"),
},
})
return {"type": "FeatureCollection", "features": features}, missing
if __name__ == "__main__":
snapshot = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
collection, missing = split(snapshot)
Path("shortlist.geojson").write_text(json.dumps(collection, indent=2), encoding="utf-8")
payload = json.dumps({"collection": collection, "missing": missing,
"exportedAt": snapshot.get("exported_at")}).replace("<", "\\u003c")
html = Path("map_template.html").read_text(encoding="utf-8").replace("__DATA__", payload)
Path("map.html").write_text(html, encoding="utf-8")
print(f"{len(collection['features'])} mapped, {len(missing)} without a location")
Render listing text as text
Leaflet's bindPopup accepts either a string or a DOM element, and its documentation warns that strings are rendered as HTML. Addresses and statuses come from an outside page, so the template builds each popup from elements and text nodes instead of concatenating an HTML string. The data itself sits in a script block of type application/json, with every less-than sign escaped, so a value can never close the tag early.
The base map uses OpenStreetMap tiles, which require visible attribution and set rules for heavy use. Keep the attribution control in place, do not prefetch tiles, and switch to a commercial or self-hosted tile service if the map becomes part of a busy product.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Property shortlist map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>#map{height:70vh} .missing{color:#8a3b12}</style></head>
<body>
<h1>Property shortlist</h1>
<div id="map"></div>
<h2>Not on the map</h2><ul id="missing"></ul>
<script type="application/json" id="shortlist">__DATA__</script>
<script>
const { collection, missing, exportedAt } = JSON.parse(document.getElementById("shortlist").textContent);
const map = L.map("map");
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const money = (v, c) => v == null ? "Unavailable"
: new Intl.NumberFormat("en-US", { style: "currency", currency: c || "USD", maximumFractionDigits: 0 }).format(v);
function line(box, label, value) { // text nodes only: listing text is never parsed as HTML
const p = document.createElement("p"), b = document.createElement("strong");
b.textContent = label + ": ";
p.append(b, document.createTextNode(value ?? "Unavailable"));
box.append(p);
}
const points = [];
for (const f of collection.features) {
const [lon, lat] = f.geometry.coordinates, p = f.properties, box = document.createElement("div");
line(box, "Your input", p.input);
line(box, "Returned address", p.address);
line(box, "Status", p.status);
line(box, p.priceLabel, money(p.price, p.currency));
line(box, "Zestimate (Zillow's estimate)", money(p.zestimate, "USD"));
line(box, "Collected", p.fetchedAt);
if (p.warning) line(box, "Check before use", p.warning);
if (p.detailUrl) {
const a = document.createElement("a");
a.href = p.detailUrl; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = "Source page";
box.append(a);
}
L.marker([lat, lon]).addTo(map).bindPopup(box); // Leaflet takes [lat, lng]
points.push([lat, lon]);
}
points.length ? map.fitBounds(points, { padding: [24, 24] }) : map.setView([39.8, -98.6], 4);
for (const m of missing) {
const li = document.createElement("li");
li.className = "missing";
li.textContent = `${m.id}: ${m.input} (${m.reason})`;
document.getElementById("missing").append(li);
}
</script></body></html>
Run it and review the result
Run the collector once, then rebuild the map as often as you like. Opening or refreshing map.html does not call the API, so panning and clicking costs nothing. Open each popup on the first run and compare your input with the returned address, especially the unit.
Use the list under the map as part of the review. Rows that failed with address_ambiguous need a corrected address or a confirmed Zillow link. Rows that succeeded but have no coordinates should be checked on the source page rather than placed by hand without a note.
python -m pip install requests
read -rsp "PropertiesAPI key: " PROPERTIESAPI_KEY; echo; export PROPERTIESAPI_KEY
python collect_snapshot.py shortlist.csv snapshot.json
python build_map.py snapshot.json
# open map.html in a browser; it contains data, not your key| What you see | What it means | What to do |
|---|---|---|
| A marker far from the expected area | Coordinates swapped, or the wrong property was matched | Check the coordinate order, then compare input and returned address |
| A row under Not on the map with an error code | The lookup failed or was not requested | Fix the input or retry later; the row stays visible |
| Price shows Unavailable | The source did not return a price for that status | Keep it unavailable; do not fill it with the Zestimate |
| A Check before use line | The response carried a meta.warning | Confirm the identity before sharing the map |
Sources and documentation
Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.
- PropertiesAPI property fields, including latitude and longitude
- PropertiesAPI batch endpoint
- The GeoJSON format specification (IETF)
- Leaflet API reference
- Leaflet download and CDN links
- OpenStreetMap tile usage policy
- OpenStreetMap copyright and attribution
- MDN: Node.textContent and why it is safer than innerHTML
Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.