Build a project

Enrich CRM Property Records Without Overwriting Your Team's Data

An import template and merge policy for adding property data to CRM records you already have: separate api columns, identity checks, conflict flags and no silent overwrites.

8 min read

Enrichment should add facts, not replace them

A CRM full of property records is the product of a lot of human work. An agent corrected the bedroom count after a showing. An analyst noted that the garage was converted. A coordinator fixed the unit number a client typed wrong. A careless enrichment job can undo all of that in one import by writing returned values over the team's fields.

This guide builds an enrichment workflow for records you already have, keyed by your own CRM record IDs. It adds property observations in their own clearly named columns, writes nothing for a match it cannot confirm, and reports disagreements between your team and the source instead of picking a winner. It does not find new leads, add owner or contact details, or connect to any particular CRM. The output is a CSV file that you load with your CRM's own import tool.

The merge policy in five rules

Write the policy down before writing code. These rules are what the script enforces, and they are what you should explain to the people whose data lives in the CRM.

  • Team fields are never written. The import file contains only the record ID and api_ columns.
  • Every observation carries its collection time in api_fetched_at, so readers know how old it is.
  • No observation is written for an unconfirmed match. Identity warnings and unit mismatches become needs_review.
  • Failed lookups clear nothing. Existing api_ values stay until a confirmed lookup replaces them.
  • Disagreements are reported in api_conflicts and resolved by a person.

The import template

Add a group of api_ fields to the property object in your CRM, and make them read-only for most users if your CRM allows it. Export the records you want to enrich with at least crm_record_id and address, plus any of zillow_url, unit, beds, baths, sqft and year_built that your team maintains. Those team columns are read for comparison only.

A confirmed Zillow link is the best input when you have one, because it removes address ambiguity. Otherwise use the full address with the unit. The table lists the columns the merge script writes back.

ColumnFilled fromNotes
crm_record_idYour exportThe key your CRM uses to update the existing record
api_zpid, api_detail_urlzpid, detailUrlReturned identity. zpid is empty for an apartment building
api_address, api_unitaddress, unitCompare with your own address before trusting the row
api_home_status, api_price, api_currencyhomeStatus, price, currencyPrice meaning depends on status; rentals are monthly
api_zestimate, api_rent_zestimatezestimate, rentZestimateZillow's estimates, labeled as such
api_beds, api_baths, api_living_area, api_year_builtbeds, baths, livingArea, yearBuiltObservations; your team's columns stay untouched
api_fetched_at, api_cachedmeta.fetched_at, meta.cachedWhen the source data was collected
api_status, api_error, api_conflictsThe merge scriptok, needs_review or failed, with reasons

Collect the property data

First turn the CRM export into a lookup list, one row per record. Then run the batch collector, which sends up to 50 addresses per request, keeps every result including failures, and stops cleanly if the account runs out of credits or hits a rate limit. Run it on a server or your own machine with the key in an environment variable; the key never belongs in a CRM field or a spreadsheet.

Start with a small export. Check the needs_review rows by hand, then widen the list.

prepare_lookup.py and collect_snapshot.py
# prepare_lookup.py: one lookup row per CRM record (prefer a confirmed Zillow link)
import csv
with open("crm_export.csv", newline="", encoding="utf-8-sig") as f, \
     open("lookup.csv", "w", newline="", encoding="utf-8") as g:
    w = csv.writer(g)
    w.writerow(["id", "input"])
    for r in csv.DictReader(f):
        w.writerow([r["crm_record_id"].strip(), (r.get("zillow_url") or r["address"]).strip()])

"""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")

Merge into an import file

merge_for_crm.py joins the snapshot to the export by record ID and applies the policy. A failed lookup produces a row with api_status failed and empty api_ columns. Before you import, set your CRM's import to skip empty values if it offers that option, so a failed row cannot clear a previous good observation.

Unit numbers are compared after removing a leading hash and leading zeros, because the same unit can appear as a padded number on one page and a plain number on another. Anything else that differs sends the row to review.

The script guards text cells that a spreadsheet could interpret as a formula, which matters when a CRM export is opened in a spreadsheet before import.

merge_for_crm.py
"""merge_for_crm.py: build crm_import.csv that only ever writes api_* columns.

Usage: python merge_for_crm.py crm_export.csv snapshot.json crm_import.csv
"""
import csv, json, sys

API_COLUMNS = {                      # CRM column -> response field
    "api_zpid": "zpid", "api_detail_url": "detailUrl", "api_address": "address",
    "api_unit": "unit", "api_home_status": "homeStatus", "api_price": "price",
    "api_currency": "currency", "api_zestimate": "zestimate",
    "api_rent_zestimate": "rentZestimate", "api_beds": "beds", "api_baths": "baths",
    "api_living_area": "livingArea", "api_year_built": "yearBuilt",
}
COMPARE = {"beds": "beds", "baths": "baths", "sqft": "livingArea", "year_built": "yearBuilt"}
OUT = ["crm_record_id", *API_COLUMNS, "api_fetched_at", "api_cached",
       "api_status", "api_error", "api_conflicts"]

def safe(v):  # stop spreadsheet formula injection in text cells
    return "'" + v if isinstance(v, str) and v[:1] in ("=", "+", "-", "@", "\t", "\r") else v

def number(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return None

def merge(team, result):
    row = {"crm_record_id": team["crm_record_id"]}
    if not result or not result["ok"]:
        row.update(api_status="failed", api_error=((result or {}).get("error") or {}).get("code", "missing"))
        return row                                   # api_* stay empty; nothing is cleared in the CRM
    d, meta = result["data"], result["meta"] or {}
    review = []
    if meta.get("warning"):
        review.append("identity warning")
    if not (d.get("zpid") or d.get("detailUrl")):
        review.append("no returned identity")
    team_unit = (team.get("unit") or "").strip().lstrip("#").lstrip("0").upper()
    api_unit = (d.get("unit") or "").lstrip("0").upper()
    if team_unit and team_unit != api_unit:
        review.append(f"unit differs: team {team_unit or '-'}, source {api_unit or '-'}")
    if review:                                       # do not write observations for an unconfirmed match
        row.update(api_status="needs_review", api_error="; ".join(review))
        return row
    row.update({col: d.get(field) for col, field in API_COLUMNS.items()})
    row.update(api_fetched_at=meta.get("fetched_at"), api_cached=meta.get("cached"), api_status="ok")
    conflicts = []
    for team_col, field in COMPARE.items():
        mine, theirs = number(team.get(team_col)), d.get(field)
        if mine is not None and theirs is not None and mine != theirs:
            conflicts.append(f"{team_col}: team {team[team_col]}, source {theirs}")
    row["api_conflicts"] = "; ".join(conflicts)       # reported, never resolved automatically
    return row

if __name__ == "__main__":
    crm_path, snap_path, out_path = sys.argv[1:4]
    with open(crm_path, newline="", encoding="utf-8-sig") as f:
        team_rows = list(csv.DictReader(f))
    results = {r["id"]: r for r in json.load(open(snap_path, encoding="utf-8"))["rows"]}
    rows = [merge(t, results.get(t["crm_record_id"].strip())) for t in team_rows]
    with open(out_path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=OUT, extrasaction="ignore")
        w.writeheader()
        w.writerows({k: safe(v) for k, v in r.items()} for r in rows)
    counts = {s: sum(r.get("api_status") == s for r in rows) for s in ("ok", "needs_review", "failed")}
    print(counts)

Handle conflicts as a review queue

api_conflicts lists fields where your team's value and the returned value differ, such as a bedroom count. Neither side is automatically right. The source page may not reflect a renovation, and a team entry may have a typo. Put conflicting rows in a CRM view or task list and let the record owner decide.

When someone resolves a conflict, they edit the team field, not the api_ field. The next enrichment run will overwrite api_ columns with the newer observation, which is exactly why decisions should never live there.

Refresh on a schedule you can afford

Each successful live lookup uses a credit, and repeat lookups of the same home within the cache window are free. Most CRM fields do not need daily refreshes. Refresh listed homes more often than off-market ones, and refresh before a decision rather than on a fixed timer when you can.

Keep the previous snapshot files. They form a history of what the source showed and when, which is more useful than a single field that silently changes.

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.