Zillow property & agent lead builder
Turn a ZIP code, a Zillow search URL, or a list of property IDs into a clean table of listings with the listing agent's contact details attached.
The key insight
The listing agent's contact details are already inside the property row. agentName, cellPhone, agentEmail, brokerName and agentLicenseNumber come back with the listing itself, and agentEmailSource tells you which lookup produced the email — so you can tell a real address from a guess without re-checking.
Measured on 2026-07-29, 75 for-sale listings across three markets:
| Market |
Listings |
cellPhone |
agentEmail |
| Austin, TX |
25 |
25 (100%) |
23 (92%) |
| Chicago, IL |
25 |
25 (100%) |
21 (84%) |
| Miami Beach, FL |
25 |
25 (100%) |
17 (68%) |
| Total |
75 |
75 (100%) |
61 (81%) |
Every email in that sample carried agentEmailSource: agent_profile_direct.
So use a waterfall. Take what the listing scrape returned, and spend on enrichment only for the ~1 row in 5 with no email. Never open with a Google Maps email extractor: it costs more per lead, and for a listing agent it frequently resolves to the brokerage's front desk rather than the person.
| Tier |
Source |
Gets |
Fires when |
| 1 |
Zillow listing row |
agent name, cell phone, email, brokerage, license |
always — already scraped |
| 2 |
Google Search (agent + brokerage name) |
a candidate brokerage website |
row still has no email |
| 3 |
Contact scraper on that website |
email / phone / socials |
row has a website but still no email |
Prerequisites
- Apify account (sign up)
- Authentication via one of:
Workflow
- Pick the mode first. This is the single most common mistake — see Mode routing below. ZIP code = discover listings; Zillow URL = the user already filtered in their browser; property IDs (
zpid) = enrich listings they already have.
- Collect inputs. Location (ZIP / URL / zpid list), for-sale vs for-rent vs recently-sold (
status_type), any price / beds / baths filters, and how many properties to cap the run at.
- Tier 1 — scrape the listings. Run the Zillow actor. Build one lead row per property from the fields in Mapping below. Rows with
agentEmail set are done; mark them contactSource: zillow-listing.
- Tier 2 — find a website (only rows still missing an email). Run
apify/google-search-scraper with one query per row: "<agentName>" "<brokerName>" real estate. Take the first organicResults[].url that is not zillow.com, realtor.com, redfin.com, or a social network.
- Tier 3 — scrape that website (only rows with a website but no email). Run
vdrmota/contact-info-scraper on the candidate URLs. Mark filled rows contactSource: website-contact. Cap this tier — it is the expensive one, and it runs on the smallest slice.
- Deliver. One row per property. Report the row count, the share with an email, and the
contactSource breakdown. Rows still without an email keep cellPhone and hdpUrl, which are enough to reach the agent.
Mode routing
| User wants |
Mode |
Key input |
| Every listing in an area |
zip |
zipCodes: ["78704"] |
| The exact search they configured on zillow.com |
url |
zillowUrl — the full address-bar URL, including searchQueryState= |
| Details for properties they already identified |
zpid |
zpids: ["119617641"] |
Filters apply in ZIP mode only. Passing beds_min, price_max or status_type alongside a zillowUrl does nothing — in URL mode the filtering already happened in the browser and lives inside the URL. In zpid mode filters are ignored too; you asked for specific properties.
Two things worth knowing
The cap is per ZIP, so cost scales with the ZIP list. maxPropertiesPerZip applies to each ZIP separately: 10 ZIPs at a cap of 5 returns up to 50 rows, and every row is billed. Multiply before you run, and confirm with the user before sweeping a long ZIP list.
A few ZIPs cannot be searched directly, and the run says so. PO-box and business-district ZIPs — 3 of the 50 most-requested ones — are not searchable as regions on Zillow's side. For those the actor searches the surrounding city and keeps only listings inside the requested ZIP, dropping the rest before they are billed. Which ZIPs those were is in RUN_SUMMARY.zipScope and in the USER_MESSAGE record. In a dense metro that fallback often finds nothing, which is a real answer about the ZIP, not a failure to retry.
Mapping (property row → lead row)
| Output column |
Source field |
address, city, state, zipcode, county |
streetAddress, city, state, zipcode, county |
price, zestimate, pricePerSqft, status |
price, zestimate, pricePerSqft, status |
beds, baths, sqft, yearBuilt |
bedrooms, bathrooms, livingArea, yearBuilt |
agentName, phone, email |
agentName, cellPhone, agentEmail |
brokerage, license |
brokerName, agentLicenseNumber |
emailProvenance |
agentEmailSource — property_details | agent_profile_direct | agent_search_fallback | not_found |
listingUrl |
hdpUrl |
contactSource |
zillow-listing | website-contact | none |
Put agentName, phone and email immediately after the address columns — that is the order a user scans a lead list in.
Always return hdpUrl, daysOnZillow and priceChange for every row. They are the three signals that let a user rank which agent to call first without reopening Zillow.
Actor routing
| Waterfall tier |
Actor ID |
Maintainer |
Best for |
| 1 — listings + agent contacts, all three modes |
afanasenko/zillow-property-agent-data-scraper |
community |
Primary. ZIP / URL / zpid in one actor, agent contact attached to every row |
| 1 — ZIP only, simpler input |
afanasenko/zillow-zip-search |
community |
When the user gives nothing but ZIP codes |
| 1 — search URL only |
afanasenko/zillow-url-search |
community |
When the user pastes a zillow.com search link |
| 2 — discover a website |
apify/google-search-scraper |
apify |
Finding the agent's or brokerage's site when the listing has no email |
| 3 — extract from the website |
vdrmota/contact-info-scraper |
community |
Deterministic email / phone / social extraction |
Prefer apify-maintained Actors where available.
Calling Actors — Apify CLI
apify actors call "afanasenko/zillow-property-agent-data-scraper" \
-i '{"mode":"zip","zipCodes":["78704"],"maxPropertiesPerZip":25,"status_type":"ForSale"}' \
--json \
--user-agent apify-awesome-skills/apify-zillow-real-estate-leads \
2>/dev/null
Fetch the input schema before building a call, so filter names come from the actor rather than from memory:
apify actors info "afanasenko/zillow-property-agent-data-scraper" --input --json \
--user-agent apify-awesome-skills/apify-zillow-real-estate-leads \
2>/dev/null
Read the rows:
apify datasets get-items DATASET_ID --format json \
--user-agent apify-awesome-skills/apify-zillow-real-estate-leads \
2>/dev/null
| Flag |
Why |
--json |
Stable machine-readable output |
--user-agent |
Apify telemetry attribution |
2>/dev/null |
Suppress progress messages that break JSON |
You can also call these Actors through the Apify MCP connector or any MCP client — cross-tool compatibility is your responsibility.
Troubleshooting
- Zero rows. Read the run's
USER_MESSAGE key-value record first — it names the cause for that specific run. In order of frequency: the search matched nothing (a PO-box or business-district ZIP has no residential listings by design, or status_type is wrong — asking for ForSale in a rentals area); the URL shape was not recognised in url mode; or the zpids were delisted. A ZIP that always returns data, for isolating the problem: 90210, no filters.
- Emails come back like
j***@e***.com. That is the free plan masking them. Do not report masked strings as contacts — say the plan is the constraint.
- Filters seem ignored. Check the mode. Filters are ZIP-mode only.
- Fewer rows than the cap for one ZIP. That ZIP has fewer matching listings than the cap —
RUN_SUMMARY says whether a cap clipped the result or the search returned everything that matched.
- Cost control. Every property enriched is billed, whether or not it carries an email, so cap the run before it starts rather than filtering afterwards. Confirm with the user before running an uncapped sweep across many ZIPs.
1---2name: apify-zillow-real-estate-leads3description: Build real-estate agent and listing lead lists from Zillow - property details plus the listing agent's email, cell phone, brokerage and license number - and fall through to web enrichment only for the rows Zillow leaves empty. Use when the user says things like "get real estate agent emails", "scrape Zillow listings", "find realtor contacts in a ZIP code", "build a list of listing agents", "Zillow lead generation", "pull homes for sale with agent contact", "recently sold comps", or "who is the listing agent". The agent's phone is in the listing row every time and the email about 4 times in 5, so this skill leads with what the property scrape already returned and only pays for contact-enrichment on the remainder - instead of routing every lead through a Google Maps email scraper, which costs more and matches the wrong business.4---56# Zillow property & agent lead builder78Turn a ZIP code, a Zillow search URL, or a list of property IDs into a clean table of listings with the listing agent's contact details attached.910## The key insight1112The listing agent's contact details are **already inside the property row**. `agentName`, `cellPhone`, `agentEmail`, `brokerName` and `agentLicenseNumber` come back with the listing itself, and `agentEmailSource` tells you which lookup produced the email — so you can tell a real address from a guess without re-checking.1314Measured on 2026-07-29, 75 for-sale listings across three markets:1516| Market | Listings | `cellPhone` | `agentEmail` |17|---|---|---|---|18| Austin, TX | 25 | 25 (100%) | 23 (92%) |19| Chicago, IL | 25 | 25 (100%) | 21 (84%) |20| Miami Beach, FL | 25 | 25 (100%) | 17 (68%) |21| **Total** | **75** | **75 (100%)** | **61 (81%)** |2223Every email in that sample carried `agentEmailSource: agent_profile_direct`.2425**So use a waterfall.** Take what the listing scrape returned, and spend on enrichment only for the ~1 row in 5 with no email. Never open with a Google Maps email extractor: it costs more per lead, and for a listing agent it frequently resolves to the brokerage's front desk rather than the person.2627| Tier | Source | Gets | Fires when |28|------|--------|------|-----------|29| 1 | Zillow listing row | agent name, cell phone, email, brokerage, license | always — already scraped |30| 2 | Google Search (agent + brokerage name) | a candidate brokerage **website** | row still has no email |31| 3 | Contact scraper on that website | email / phone / socials | row has a website but still no email |3233## Prerequisites3435- Apify account ([sign up](https://apify.com))36- Authentication via one of:37 - `apify login` (OAuth, if using the Apify CLI)38 - `APIFY_TOKEN` environment variable39 - Token from [Apify Console → Settings → Integrations](https://console.apify.com/settings/integrations)4041## Workflow42431. **Pick the mode first.** This is the single most common mistake — see *Mode routing* below. ZIP code = discover listings; Zillow URL = the user already filtered in their browser; property IDs (`zpid`) = enrich listings they already have.442. **Collect inputs.** Location (ZIP / URL / zpid list), for-sale vs for-rent vs recently-sold (`status_type`), any price / beds / baths filters, and how many properties to cap the run at.453. **Tier 1 — scrape the listings.** Run the Zillow actor. Build one lead row per property from the fields in *Mapping* below. Rows with `agentEmail` set are done; mark them `contactSource: zillow-listing`.464. **Tier 2 — find a website (only rows still missing an email).** Run `apify/google-search-scraper` with one query per row: `"<agentName>" "<brokerName>" real estate`. Take the first `organicResults[].url` that is not zillow.com, realtor.com, redfin.com, or a social network.475. **Tier 3 — scrape that website (only rows with a website but no email).** Run `vdrmota/contact-info-scraper` on the candidate URLs. Mark filled rows `contactSource: website-contact`. Cap this tier — it is the expensive one, and it runs on the smallest slice.486. **Deliver.** One row per property. Report the row count, the share with an email, and the `contactSource` breakdown. Rows still without an email keep `cellPhone` and `hdpUrl`, which are enough to reach the agent.4950## Mode routing5152| User wants | Mode | Key input |53|---|---|---|54| Every listing in an area | `zip` | `zipCodes: ["78704"]` |55| The exact search they configured on zillow.com | `url` | `zillowUrl` — the full address-bar URL, including `searchQueryState=` |56| Details for properties they already identified | `zpid` | `zpids: ["119617641"]` |5758**Filters apply in ZIP mode only.** Passing `beds_min`, `price_max` or `status_type` alongside a `zillowUrl` does nothing — in URL mode the filtering already happened in the browser and lives inside the URL. In `zpid` mode filters are ignored too; you asked for specific properties.5960## Two things worth knowing6162**The cap is per ZIP, so cost scales with the ZIP list.** `maxPropertiesPerZip` applies to each ZIP separately: 10 ZIPs at a cap of 5 returns up to 50 rows, and every row is billed. Multiply before you run, and confirm with the user before sweeping a long ZIP list.6364**A few ZIPs cannot be searched directly, and the run says so.** PO-box and business-district ZIPs — 3 of the 50 most-requested ones — are not searchable as regions on Zillow's side. For those the actor searches the surrounding city and keeps only listings inside the requested ZIP, dropping the rest before they are billed. Which ZIPs those were is in `RUN_SUMMARY.zipScope` and in the `USER_MESSAGE` record. In a dense metro that fallback often finds nothing, which is a real answer about the ZIP, not a failure to retry.6566## Mapping (property row → lead row)6768| Output column | Source field |69|---|---|70| `address`, `city`, `state`, `zipcode`, `county` | `streetAddress`, `city`, `state`, `zipcode`, `county` |71| `price`, `zestimate`, `pricePerSqft`, `status` | `price`, `zestimate`, `pricePerSqft`, `status` |72| `beds`, `baths`, `sqft`, `yearBuilt` | `bedrooms`, `bathrooms`, `livingArea`, `yearBuilt` |73| `agentName`, `phone`, `email` | `agentName`, `cellPhone`, `agentEmail` |74| `brokerage`, `license` | `brokerName`, `agentLicenseNumber` |75| `emailProvenance` | `agentEmailSource` — `property_details` \| `agent_profile_direct` \| `agent_search_fallback` \| `not_found` |76| `listingUrl` | `hdpUrl` |77| `contactSource` | `zillow-listing` \| `website-contact` \| `none` |7879Put `agentName`, `phone` and `email` immediately after the address columns — that is the order a user scans a lead list in.8081Always return `hdpUrl`, `daysOnZillow` and `priceChange` for every row. They are the three signals that let a user rank which agent to call first without reopening Zillow.8283## Actor routing8485| Waterfall tier | Actor ID | Maintainer | Best for |86|---|---|---|---|87| 1 — listings + agent contacts, all three modes | `afanasenko/zillow-property-agent-data-scraper` | community | **Primary.** ZIP / URL / zpid in one actor, agent contact attached to every row |88| 1 — ZIP only, simpler input | `afanasenko/zillow-zip-search` | community | When the user gives nothing but ZIP codes |89| 1 — search URL only | `afanasenko/zillow-url-search` | community | When the user pastes a zillow.com search link |90| 2 — discover a website | `apify/google-search-scraper` | apify | Finding the agent's or brokerage's site when the listing has no email |91| 3 — extract from the website | `vdrmota/contact-info-scraper` | community | Deterministic email / phone / social extraction |9293Prefer `apify`-maintained Actors where available.9495## Calling Actors — Apify CLI9697 apify actors call "afanasenko/zillow-property-agent-data-scraper" \98 -i '{"mode":"zip","zipCodes":["78704"],"maxPropertiesPerZip":25,"status_type":"ForSale"}' \99 --json \100 --user-agent apify-awesome-skills/apify-zillow-real-estate-leads \101 2>/dev/null102103Fetch the input schema before building a call, so filter names come from the actor rather than from memory:104105 apify actors info "afanasenko/zillow-property-agent-data-scraper" --input --json \106 --user-agent apify-awesome-skills/apify-zillow-real-estate-leads \107 2>/dev/null108109Read the rows:110111 apify datasets get-items DATASET_ID --format json \112 --user-agent apify-awesome-skills/apify-zillow-real-estate-leads \113 2>/dev/null114115| Flag | Why |116|------|-----|117| `--json` | Stable machine-readable output |118| `--user-agent` | Apify telemetry attribution |119| `2>/dev/null` | Suppress progress messages that break JSON |120121You can also call these Actors through the [Apify MCP connector](https://mcp.apify.com) or any MCP client — cross-tool compatibility is your responsibility.122123## Troubleshooting124125- **Zero rows.** Read the run's `USER_MESSAGE` key-value record first — it names the cause for that specific run. In order of frequency: the search matched nothing (a PO-box or business-district ZIP has no residential listings by design, or `status_type` is wrong — asking for `ForSale` in a rentals area); the URL shape was not recognised in `url` mode; or the zpids were delisted. A ZIP that always returns data, for isolating the problem: `90210`, no filters.126- **Emails come back like `j***@e***.com`.** That is the free plan masking them. Do not report masked strings as contacts — say the plan is the constraint.127- **Filters seem ignored.** Check the mode. Filters are ZIP-mode only.128- **Fewer rows than the cap for one ZIP.** That ZIP has fewer matching listings than the cap — `RUN_SUMMARY` says whether a cap clipped the result or the search returned everything that matched.129- **Cost control.** Every property enriched is billed, whether or not it carries an email, so cap the run before it starts rather than filtering afterwards. Confirm with the user before running an uncapped sweep across many ZIPs.