Skip to main content
Thirdwatchthirdwatch
Real estate

Build Real Estate Agent Lead Lists From Zillow Listings

Turn active Zillow listings in any US metro into a contactable list of listing agents and brokerages, with agent name, phone, licence and listing volume.

Jul 28, 2026 · 7 min read · 1,737 words
See the scraper →

To build a real estate agent lead list, run the Zillow Suite Scraper with searchMode: "forSale" across a metro's ZIP codes and includeDetails: true. Each row returns listing_agent_name, listing_agent_phone, listing_agent_license and brokerage_name alongside the property. Group by agent, count listings, and you have a ranked list of agents who are demonstrably transacting right now — the buyers for mortgage, title, photography and proptech products.

Why scrape Zillow for real estate agent leads

Every active listing is a signal that a specific agent has inventory, a commission cheque pending, and a budget cycle you can enter. That is a far stronger buying signal than a licence roster: the National Association of Realtors reports roughly 1.4 million members, but only a fraction list a property in any given quarter, and the rest are effectively dead leads for anyone selling listing photography, sign installation, transaction coordination, title services, mortgage referrals or CRM software.

Listing data inverts the problem. Instead of buying a static agent database and hoping, you start from the transactions and work back to the people. An agent with four active listings in a ZIP code this month is spending money on marketing this month. A brokerage with sixty listings across a metro has an operations budget and a procurement process. Both are visible in public listing pages.

The practical output is a table with one row per agent: name, phone, licence number, brokerage, listing count, median list price, and the ZIP codes they work. That is enough to segment by territory and price band before the first call, which is what separates a workable list from a spreadsheet nobody uses.

How does this compare to the alternatives?

Three routes exist, and they differ mostly in who absorbs the maintenance. Zillow publishes no public listings API, so every option is some form of scraping. Writing it yourself is straightforward for a day and painful thereafter: the page payload shape shifts, sessions get challenged, and pagination has undocumented ceilings. A generic scraping API hands back raw HTML and leaves the agent-field extraction, normalisation and deduplication to you. A purpose-built Actor returns typed rows with the agent block already parsed.

Approach Cost model Reliability Setup time Maintenance
DIY Python scraper Your servers plus your time Breaks on layout and payload changes 2-5 days Ongoing; you own every block and schema shift
Generic scraping API Subscription, priced per request Fetches the page, does not parse agents 1-2 days You still own extraction and normalisation
Zillow Suite Scraper Transparent pay-per-result Session rotation and retries handled Under 10 minutes None; schema drift is absorbed upstream

How to build a Zillow agent lead list in 4 steps

How do I pull the active listings for a target metro?

Start with the search layer only. searchMode: "forSale" returns active inventory, and one entry per ZIP code beats one broad city query because Zillow caps a single search area at roughly 980 listings.

from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")

run = client.actor("thirdwatch/zillow-suite-scraper").call(run_input={
    "queries": ["78701", "78702", "78704", "78745", "78748"],
    "searchMode": "forSale",
    "maxResults": 300,
    "sortBy": "newest",
    "minPrice": 250000,
    "homeTypes": ["houses", "townhomes", "condos"],
})

listings = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(len(listings), "listings")
print(listings[0]["agent_name"], "|", listings[0]["broker_name"])

Search rows already carry agent_name and broker_name from the listing card. That is enough to size a market and rank brokerages, but it has no phone number and no licence, so it is a scouting pass rather than the deliverable.

How do I get the listing agent's phone and licence number?

Set includeDetails: true. The Actor then visits each property page and merges the property record into the row, which is where the contact block lives: listing_agent_name, listing_agent_phone, listing_agent_email, listing_agent_license, brokerage_name and brokerage_phone.

run = client.actor("thirdwatch/zillow-suite-scraper").call(run_input={
    "queries": ["78701", "78702", "78704"],
    "searchMode": "forSale",
    "maxResults": 300,
    "includeDetails": True,
    "maxDetailPages": 120,
    "sortBy": "newest",
})

rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
contactable = [r for r in rows if r.get("listing_agent_phone")]
print(f"{len(contactable)}/{len(rows)} rows have a listing agent phone")

maxDetailPages is the cost lever, and it is the one most people miss. Detail mode costs one extra page load per listing, so a 300-listing ZIP code is 300 extra fetches. Capping at 120 enriches the newest slice — the listings that just went live, whose agents are actively spending — and leaves the long tail unenriched.

How do I keep enrichment cost proportional to the list I actually need?

Filter before you enrich, not after. Every filter you push into the run input removes listings from the detail queue, which is where the time and the per-result spend go.

run_input = {
    "queries": ["78701", "78702", "78704", "78745"],
    "searchMode": "forSale",
    # Only listings that moved this week are worth a detail fetch.
    "sortBy": "newest",
    # Price band defines the vendor's addressable segment.
    "minPrice": 400000,
    "maxPrice": 1500000,
    "minBeds": 3,
    "homeTypes": ["houses", "townhomes"],
    # Search wide, enrich narrow.
    "maxResults": 400,
    "includeDetails": True,
    "maxDetailPages": 80,
}

Three rules hold up in practice. Sort by newest so the enriched slice is the freshest inventory. Use minPrice and maxPrice to match the segment your product actually serves — a title company chasing jumbo transactions has no use for a $180,000 condo's agent. And keep maxDetailPages well below maxResults, because a single agent usually appears on several listings, so enriching every row buys you duplicates rather than new contacts.

How do I collapse listings into one row per agent?

The dataset is one row per property. Your CRM wants one row per person, with listing volume as the qualification score.

from collections import defaultdict
from statistics import median

agents = defaultdict(lambda: {"listings": [], "zips": set()})

for r in rows:
    name = r.get("listing_agent_name")
    if not name or not r.get("listing_agent_phone"):
        continue
    key = (name.strip().lower(), (r.get("brokerage_name") or "").strip().lower())
    a = agents[key]
    a["agent_name"] = name
    a["brokerage_name"] = r.get("brokerage_name")
    a["phone"] = r.get("listing_agent_phone")
    a["email"] = r.get("listing_agent_email")
    a["license"] = r.get("listing_agent_license")
    a["listings"].append(r["price"])
    a["zips"].add(r.get("zip_code"))
    a.setdefault("evidence_url", r["property_url"])

leads = sorted(
    (
        {
            "agent_name": a["agent_name"],
            "brokerage_name": a["brokerage_name"],
            "phone": a["phone"],
            "email": a["email"],
            "license": a["license"],
            "active_listings": len(a["listings"]),
            "median_list_price": median([p for p in a["listings"] if p]),
            "territory": sorted(z for z in a["zips"] if z),
            "evidence_url": a["evidence_url"],
        }
        for a in agents.values()
        if any(a["listings"])
    ),
    key=lambda x: x["active_listings"],
    reverse=True,
)

active_listings is your qualification score, median_list_price is your segment, and evidence_url is the specific listing you can reference on the call. Keeping that URL on the record is not decoration — it is how you justify the outreach and how a prospect verifies you are not a cold-list spammer.

How do I run this compliantly before I contact anyone?

Public listing data being scrapeable does not make outreach unregulated. In the US, telephone prospecting sits under the FTC's Telemarketing Sales Rule and the National Do Not Call Registry, and commercial email sits under CAN-SPAM. Business-to-business calls have narrower exemptions than people assume, and several states layer their own rules on top.

import csv

with open("agent_leads.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=[
        "agent_name", "brokerage_name", "phone", "email", "license",
        "active_listings", "median_list_price", "territory", "evidence_url",
    ])
    w.writeheader()
    for lead in leads:
        lead = dict(lead, territory="|".join(lead["territory"]))
        w.writerow(lead)

Export with provenance attached, screen the phone column against your suppression and DNC process, and honour opt-outs at the person level rather than the listing level. Nothing here is legal advice; check it with counsel for your jurisdiction.

Sample output

Each dataset row is a property with the agent block merged in. Two trimmed records:

[
  {
    "zpid": "29438012",
    "property_url": "https://www.zillow.com/homedetails/1200-W-6th-St-Austin-TX-78703/29438012_zpid/",
    "source_query": "78703",
    "search_mode": "forSale",
    "listing_status": "FOR_SALE",
    "address": "1200 W 6th St, Austin, TX 78703",
    "city": "Austin",
    "state": "TX",
    "zip_code": "78703",
    "price": 875000,
    "currency": "USD",
    "bedrooms": 3,
    "bathrooms": 2.5,
    "living_area_sqft": 2140,
    "price_per_sqft": 408.88,
    "home_type": "SINGLE_FAMILY",
    "days_on_zillow": 6,
    "listed_date": "2026-07-22T00:00:00+00:00",
    "agent_name": "J. Ramirez",
    "broker_name": "Redacted Realty Group",
    "listing_agent_name": "Jordan Ramirez",
    "listing_agent_phone": "512-555-0142",
    "listing_agent_email": null,
    "listing_agent_license": "TX-0654321",
    "brokerage_name": "Redacted Realty Group",
    "brokerage_phone": "512-555-0100",
    "mls_id": "4821903",
    "listing_last_updated": "2026-07-26T14:02:00+00:00",
    "scraped_at": "2026-07-28T09:14:03+00:00"
  },
  {
    "zpid": "29511884",
    "property_url": "https://www.zillow.com/homedetails/908-E-Monroe-St-Austin-TX-78704/29511884_zpid/",
    "source_query": "78704",
    "search_mode": "forSale",
    "listing_status": "PENDING",
    "price": 649000,
    "zip_code": "78704",
    "days_on_zillow": 19,
    "listing_agent_name": "Priya N.",
    "listing_agent_phone": "512-555-0177",
    "listing_agent_license": "TX-0712345",
    "brokerage_name": "Lakeline Partners",
    "scraped_at": "2026-07-28T09:14:41+00:00"
  }
]

Field notes: agent_name and broker_name come from the search card and are often abbreviated, while listing_agent_name and brokerage_name come from the property page and are the ones to key on. listing_agent_license is the cleanest deduplication key when an agent moves brokerage. mls_id and listing_last_updated let you prove recency. listing_agent_email is null far more often than not — plan for phone-first outreach.

Common pitfalls

The ~980 ceiling per search area. Zillow serves 41 listings per page and stops after roughly 24 pages, so a city query reports tens of thousands of matches and hands over the first few hundred. Split the metro into ZIP codes or neighbourhoods; rows deduplicate on zpid across queries, so overlap is safe.

Detail mode is the expensive half. Contact fields exist only when includeDetails is true, and each enriched listing costs an extra page load. Since one agent usually holds several listings, enriching everything mostly buys duplicates. Cap it with maxDetailPages.

Not every listing has an agent. New construction, Zillow-owned inventory and agent-withheld listings routinely omit the contact block. Missing values come back as null rather than guessed, so filter on listing_agent_phone before counting your list.

A share of requests get challenged. Zillow rate-limits aggressively and rejects a minority of sessions outright. The Actor retries on a fresh session with backoff, which recovers almost all of them, but an aggressive run can finish slightly short of maxResults on some queries. Zillow also serves US inventory to US traffic only, so keep the default proxy configuration.

Deduplication is per run. Use zpid for properties and listing_agent_license for people as primary keys in your own store if you are appending week over week.

Related use cases

Frequently asked questions

Does Zillow publish listing agent phone numbers?

Usually yes. Property pages carry the listing agent name, phone, licence number and brokerage, and the Actor returns them as listing_agent_phone, listing_agent_license and brokerage_name. Email appears far less often and is returned only when the listing itself publishes it.

How many agents can I pull from one city?

Zillow stops paginating a single search area at roughly 980 listings, and agents repeat across listings, so one city query typically yields a few hundred unique agents. Split the metro into ZIP codes to go deeper; rows deduplicate on zpid.

Is it legal to contact agents found this way?

The listing data is public, but outreach is regulated separately. US calls fall under the FTC Telemarketing Sales Rule and the National Do Not Call Registry, and email falls under CAN-SPAM. Screen against those before dialling, and keep the source URL on every record.

Why are the agent fields empty on some rows?

Agent contact fields only exist when includeDetails is true, and even then new construction, Zillow-owned inventory and agent-withheld listings often omit them. Missing values come back as null rather than being guessed, so you can filter them out cleanly.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.