Build a project

Turn a Property Shortlist into a Shareable Research Packet

Generate a printable property research report from a saved snapshot: source links, collection times, missing fields and analyst notes kept apart from the data, with no copied listing photos.

7 min read

A packet someone else can trust

At some point a shortlist leaves your screen. A client wants to read it on a train, a partner wants to mark it up, or an investment committee wants a document in the meeting folder. A dashboard does not travel well and a raw spreadsheet hides its caveats. A research packet does both jobs: it is a printable page per property that shows what was observed, where it came from, when it was collected, what was missing and what your team thinks.

This guide builds that packet as a single HTML file generated from a saved snapshot. You can print it, save it as PDF from the browser or send the file itself. It runs locally, uses only the Python standard library and never needs the API key, because it reads data you already collected.

The rules the template follows

A packet is persuasive by nature, so the template is built to be honest by default. These rules come straight from the code below.

  • Every property shows what was requested next to what was returned.
  • Every observation shows its source link and collection time.
  • Missing fields are listed by name instead of disappearing from the page.
  • Failed lookups stay in the packet with their error code.
  • Estimates are labeled as Zillow's estimates, and price is labeled by listing status.
  • Analyst notes are visually separate and attributed to a named author.
  • Listing photos are not embedded; readers follow the source link to see them.

Prepare the two inputs

The first input is a snapshot of full property responses. If you followed the map or CRM guides, you already have one in the expected shape: an exported_at time and a list of rows, each with your id, your input and the API's ok, data, meta and error. The property dashboard guide shows another way to build a snapshot from an address list.

The second input is notes.csv, where analysts write their observations keyed by the same id. Keeping notes in a separate file is deliberate. It stops opinions from being mixed into source fields, and it lets you regenerate the packet with fresh data without losing anyone's notes.

notes.csv (illustrative)
id,author,note
home-a,J. Rivera,"Walked the block: busy road on the north side. Ask about the roof age."
home-b,J. Rivera,"Price cut noted in the source history; confirm with the listing agent."

Generate the packet

make_packet.py writes one section per row. The price line is labeled asking price, monthly asking rent or sale price depending on homeStatus, so a reader never has to guess. Every value passes through an escaping function before it reaches the HTML, which matters because addresses, descriptions and notes are text from people and pages you do not control.

The facts table is short on purpose. It covers the fields readers ask about first, and it ends with a sentence that names every field that came back empty. Add fields if your audience needs them, but keep the missing-field line, because silence about a gap reads as a claim that there was nothing to report.

Source links are only rendered for zillow.com pages, and the header paragraph states the data's limits in plain language, including that the packet is not an appraisal and that PropertiesAPI is not affiliated with Zillow.

make_packet.py
"""make_packet.py: snapshot.json + notes.csv -> packet.html (print or save as PDF from the browser).

notes.csv columns: id, author, note   (analyst notes, kept apart from source data)
"""
import csv, json, sys
from collections import defaultdict
from datetime import datetime, timezone
from html import escape

FIELDS = [  # (label, response field) shown for every property
    ("Status", "statusText"), ("Home type", "homeType"), ("Beds", "beds"), ("Baths", "baths"),
    ("Living area (sqft)", "livingArea"), ("Lot (sqft)", "lotSize"), ("Year built", "yearBuilt"),
    ("Monthly HOA", "monthlyHoaFee"), ("Annual tax", "taxAnnualAmount"),
    ("Zestimate (Zillow's estimate)", "zestimate"), ("Rent Zestimate (Zillow's estimate)", "rentZestimate"),
]
LISTED = {"FOR_SALE", "PENDING", "COMING_SOON", "FORECLOSURE"}

def price_label(status):
    return ("Asking price" if status in LISTED else "Monthly asking rent" if status == "FOR_RENT"
            else "Sale price" if status == "RECENTLY_SOLD" else "Price")

def cell(v):
    if v is None:
        return '<span class="na">Not returned</span>'
    return escape(f"{v:,}" if isinstance(v, (int, float)) and not isinstance(v, bool) else str(v))

def section(row, notes):
    d, meta = row.get("data") or {}, row.get("meta") or {}
    h = [f'<section class="property"><h2>{escape(row["id"])}</h2>',
         f'<p class="input">Requested: {escape(row["input"])}</p>']
    if not row["ok"]:
        code = (row.get("error") or {}).get("code", "unknown")
        h.append(f'<p class="na">No data: lookup failed ({escape(code)}). Kept in the packet on purpose.</p>')
    else:
        h.append(f'<p><strong>Returned:</strong> {cell(d.get("address"))}</p>')
        if meta.get("warning"):
            h.append(f'<p class="warn">Check identity: {escape(meta["warning"])}</p>')
        year = d.get("yearBuilt")
        rows = [(price_label(d.get("homeStatus")), d.get("price"))] + [
            (l, (str(year) if year is not None else None) if f == "yearBuilt" else d.get(f)) for l, f in FIELDS]
        h.append("<table>" + "".join(f"<tr><th>{escape(l)}</th><td>{cell(v)}</td></tr>" for l, v in rows) + "</table>")
        missing = [l for l, v in rows if v is None]
        h.append(f'<p class="na">Not returned for this property: {escape(", ".join(missing)) or "none of the fields above"}.</p>')
        url = d.get("detailUrl") or ""
        link = f'<a href="{escape(url)}">{escape(url)}</a>' if url.startswith("https://www.zillow.com/") else "none"
        h.append(f'<p class="src">Source page: {link}<br>Collected: {cell(meta.get("fetched_at"))}'
                 f' {"(from cache)" if meta.get("cached") else ""}</p>')
    h.append("<h3>Analyst notes</h3>")
    h.append("".join(f'<blockquote>{escape(n["note"])}<cite>{escape(n["author"])}</cite></blockquote>'
                     for n in notes) or '<p class="na">No notes yet.</p>')
    return "".join(h) + "</section>"

CSS = """body{font:15px/1.45 Georgia,serif;max-width:46rem;margin:2rem auto;color:#222}
th{text-align:left;font-weight:normal;color:#555;padding-right:1rem} .na{color:#8a5a00}
.warn{color:#a40000;font-weight:bold} blockquote{border-left:3px solid #2F5D8C;margin:.5rem 0;padding-left:.75rem}
cite{display:block;font-size:.85em;color:#555} .src{font-size:.85em;word-break:break-all}
@media print{body{margin:0;max-width:none} .property{break-inside:avoid;break-before:page} a{color:inherit}}"""

if __name__ == "__main__":
    snap = json.load(open(sys.argv[1], encoding="utf-8"))
    notes = defaultdict(list)
    with open(sys.argv[2], newline="", encoding="utf-8-sig") as f:
        for n in csv.DictReader(f):
            notes[n["id"]].append(n)
    made = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    body = "".join(section(r, notes[r["id"]]) for r in snap["rows"])
    page = f"""<!doctype html><html lang="en"><meta charset="utf-8"><title>Research packet</title>
<style>{CSS}</style><h1>Property research packet</h1>
<p>Prepared {made} from a snapshot exported {escape(snap.get("exported_at") or "unknown")}.
Property data is as shown on each public Zillow page when collected, may be incomplete and is not
an appraisal. Zestimates are Zillow's estimates. Photos are not reproduced; follow the source links.
Analyst notes are opinions of the named author. Not affiliated with Zillow.</p>{body}</html>"""
    open("packet.html", "w", encoding="utf-8").write(page)
    print(f"packet.html: {len(snap['rows'])} properties")

Print it or save it as PDF

Open packet.html in a browser and use the print dialog. The stylesheet includes a print media query that starts each property on a new page and avoids splitting a property across pages. Choosing save as PDF in the same dialog produces a file you can attach to an email or a data room.

Look at the first page before you send anything. Check that requested and returned addresses match, that warnings are visible, and that every note is one you are comfortable sharing with the recipient.

Build the packet
python make_packet.py snapshot.json notes.csv
# open packet.html, then print or save as PDF

Respect data use and photo rights

Listing photos usually belong to the photographer or the listing brokerage. The API returns photo links, never copies, and the packet follows the same approach by linking to the source page. If you need images in a packet, get permission from whoever holds the rights.

Check that sharing the data fits the terms you work under, especially if the packet goes to a client or outside your company. The data-rights page summarizes how PropertiesAPI data may be used. Agent and broker names in a response belong to past or current listings, so leave them out of a packet unless your recipient needs them for a legitimate reason.

Keep the packet current

A packet is a snapshot. Its header records when it was prepared and when the data was exported, and each property records when its data was collected. A cached response keeps its original collection time, so the packet marks those rows as from cache.

When the packet needs refreshing, collect a new snapshot, keep notes.csv as it is and regenerate. Archive the old packet next to the new one if decisions were made from it.

Time shownWhere it comes fromWhat it tells the reader
PreparedWhen make_packet.py ranWhen this document was produced
Snapshot exportedexported_at in the snapshotWhen the collection run finished
Collectedmeta.fetched_at per propertyWhen the source data was read

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.