Purpose
Given a subject property (Redfin URL, MLS/property ID, full street address, or lat+lon), return recent comparable sales ("comps") in the surrounding area as structured JSON. Every dimension Redfin's "Recently Sold" filter exposes is mapped to the underlying query-string keys (recency, distance, price, beds, baths, sqft, lot, year built, property type, days-on-market, sort order, pagination). The skill also returns the subject's address, lat/lon, Redfin Estimate, and most-recent sale info so the caller can frame the comp set against the subject. Read-only — never click Save, Tour, Contact Agent, Get Pre-Approved, Sign In, or any mutation control.
When to Use
- Pulling 3–25 recent local sales to support an AVM, broker price opinion, or appraisal review.
- Bulk extraction across many subjects (one comp set per address) for portfolio analysis or property-tax challenges.
- Surfacing "what did houses like this go for" for a buyer/seller chat agent.
- Anywhere you'd otherwise screenshot Redfin's Recently-Sold map UI — the JSON endpoint is 10–50× faster, cheaper, and exposes the full property record (beds/baths/sqft/lot/year/photos/MLS#/agent) per result.
Workflow
Redfin's web UI is a thin client over a public-but-undocumented JSON endpoint at https://www.redfin.com/stingray/api/gis?.... No auth or cookies required, but a residential proxy is mandatory — Redfin's edge throttles non-residential IPs aggressively (intermittent 403/captcha on 2–3 sequential requests from the same datacenter IP). The optimal surface is a single browserless_agent call that gotos the gis URL directly with a residential proxy and reads the JSON out of the page body in-page; the rendered web UI is only used as a fallback when the proxy pool is exhausted, when the subject must be resolved from a raw street address (autocomplete is cookie-walled), or when the Redfin Estimate / list-price-vs-sale delta must be surfaced (those are not in the gis JSON — they only live on the property detail page).
Resolve the subject into
{propertyId, regionId, regionType, market, lat, lon, zip}.- Redfin URL given → propertyId is the trailing path segment after
/home/. Open the URL with a residential-proxybrowserless_agentgoto(a real browser follows Redfin's slug-rewrite redirect natively) andevaluateover the JSON-LD<script type="application/ld+json">blocks in-page:mainEntity.address,mainEntity.geo.{latitude,longitude},mainEntity.numberOfBedrooms,mainEntity.numberOfBathroomsTotal,mainEntity.floorSize.value. The breadcrumb block (BreadcrumbList) carries the city's region URL/city/{regionId}/{ST}/{City}— pull{regionId}from there. (Region type for city is 6.) The Redfin Estimate is in the rendered HTML as<div class="price smallerFont">$X,XXX,XXX</div>insideid="redfin-estimate"— read it in the sameevaluate. - Property ID given → fetch
https://www.redfin.com/CA/-/home/{id}(Redfin rewrites the slug; follow redirects), then the same JSON-LD extraction. - Lat/lon given → use a
poly=rectangle of±deltaaround the point (see step 3). No region required. Pickmarket=from the lat/lon's metro (e.g.sanfrancisco,losangeles,seattle,boston,newyork,chicago,dallasfortworth,dc);marketis a required argument on the gis endpoint and gates the sold-filter behavior. - Street address given → Redfin's
/stingray/do/location-autocomplete?location=...returns 403 without a warm session. The direct gis-navigation path cannot resolve a raw street address to a propertyId. Browser-fallback: a single residential-proxybrowserless_agentcall whosecommandsarray chains agoto(url: "https://www.redfin.com/"), atypeof the address into the search input, aclickon the first autocomplete suggestion (or a submit of the box), then anevaluatereturninglocation.href— the redirected URL contains the propertyId. Then go back to the direct gis path with{propertyId}. Many callers can avoid this by accepting a ZIP code instead (region_type=2®ion_id={zip}) and lat/lon for distance-ranking.
- Redfin URL given → propertyId is the trailing path segment after
Pick the recency window. Map the caller's
sale_recencytosold_within_days:Caller value sold_within_daysLast 1 month30Last 3 months90Last 6 months180Last 1 year365Last 2 years730Last 3 years1095explicit sold_after=YYYY-MM-DDcompute days delta from today, round up to nearest preset (Redfin only honors discrete buckets) Pick the search area.
- Region-by-id (preferred when the subject's city or ZIP is known):
region_type=2®ion_id={ZIP}for ZIP-scoped, orregion_type=6®ion_id={cityId}for city-scoped. Use ZIP for tight comp sets (typical appraisal radius); use city when the ZIP is sparse. - Bounded radius / lat-lon recentered: pass
poly={lon1}+{lat1},{lon2}+{lat2},{lon3}+{lat3},{lon4}+{lat4},{lon1}+{lat1}(the rectangle must close — first vertex repeated;+between lon and lat,%2Cbetween vertices). Compute the rectangle assubjectLat ± (miles/69)andsubjectLon ± (miles / (69·cos(lat))).polyrequiresmarket=<metro>to be supplied or it silently returns the entire metro's homes. - Map bounds passed by caller: drop straight into
poly=in the same lon+lat order.
- Region-by-id (preferred when the subject's city or ZIP is known):
Build the request to
https://www.redfin.com/stingray/api/gis?.... Required scaffolding params:al=1&v=8&start=0&page_number=1&num_homes={limit}&include_nearby_homes=true&market={metro}&mpt=13&uipt={uipt-csv}&status=9. Then layer each filter onto the query string:Caller filter Query param(s) Notes Sale recency sold_within_days={N}Required to switch the endpoint into sold-comps mode. Without it, the same URL returns ACTIVE listings — statusalone is not enough.Distance / radius poly=...rectangle (see step 3) orregion_type=2®ion_id={ZIP}for "same ZIP"Redfin does not expose a pure-radius param; emulate via bounding box. Beds (min) num_beds={N}NOT min_beds. Aliases silently dropped.Beds (max) max_num_beds={N}Beds (exact) num_beds=N&max_num_beds=NBaths (min, half-bath OK) num_baths={N.0|N.5}Half-bath increments work: num_baths=2.5.Baths (max) max_num_baths={N}Price (min) min_price={N}Raw dollars (no $/k/M suffix). Price (max) max_price={N}SqFt (min) min_sqft={N}(plusmin_listing_approx_size={N}— Redfin's filter page sends both)SqFt (max) max_sqft={N}(plusmax_listing_approx_size={N})Lot size (min, sqft) min_parcel_size={sqft}1 acre = 43560 sqft. Lot size (max, sqft) max_parcel_size={sqft}Year built (min) min_year_built={YYYY}Year built (max) max_year_built={YYYY}Property type uipt=CSV of1,2,3,4,5,7,81=Single-Family, 2=Condo, 3=Townhouse, 4=Multi-Family, 5=Land, 7=Mobile/Manufactured, 8=Co-op. Combine freely. Days-on-market max time_on_market_range={N}-(note trailing dash = "N or fewer")Stories (min/max) min_stories={N}&max_stories={N}Optional — also requires sf=1,2,3,5,6,7(search-feature mask).HOA max No URL param. Filter client-side on home.hoa.valueafter fetch.Redfin's filter UI redirects when max-hoa-fee=…is passed → confirmed unsupported on the sold-comps endpoint.Sold-above/below/at list No URL param. Filter client-side: fetch each home's property-page JSON-LD to get list price, then compute delta. Redfin's UI does not expose this as a query filter for the sold view. Has-photos toggle No URL param. Filter client-side on home.numPictures > 0.Redfin's URL filter has-photosredirects away → confirmed unsupported.Sort: most-recent sale ord=redfin-recommended-asc(default; "Recently Sold" page implicitly sorts by sale recency in display order)Redfin does NOT expose a sort=newest/sold-date-descURL key.ord=last-sale-date-descis parsed but does NOT actually reorder — the response is identical to recommended order. To get most-recent sales first, sort client-side onhome.soldDateafter fetch.Sort: price ↓ ord=price-desc&sf=1,2,3,5,6,7Sort: price ↑ ord=price-asc&sf=1,2,3,5,6,7Sort: $/sqft ↑/↓ ord=dollars-per-sq-ft-asc|-desc&sf=1,2,3,5,6,7Sort: sqft ↑/↓ ord=square-footage-asc|-desc&sf=1,2,3,5,6,7Sort: closest to subject ord=distance-ascOnly meaningful when poly=is set; otherwise distance is from region centroid.Limit / page size num_homes={1..350}Hard cap 350 per page. Pagination page_number={N}&start={(N-1)*num_homes}Both required; otherwise Redfin returns page 1. Fetch & decode: one
browserless_agentcall navigates straight to the gis URL and reads the JSON body in-page (a browser navigating to the endpoint renders the raw JSON as page text):{ "proxy": { "proxy": "residential" }, "commands": [ { "method": "goto", "params": { "url": "https://www.redfin.com/stingray/api/gis?<query>", "waitUntil": "load", "timeout": 45000 } }, { "method": "evaluate", "params": { "content": "(()=>{ let t = document.body.innerText; if (t.startsWith('{}&&')) t = t.slice(4); const p = JSON.parse(t).payload; return JSON.stringify({ homes: (p.originalHomes&&p.originalHomes.homes)||[], nearby: (p.nearbyHomes&&p.nearbyHomes.homes)||[], nearbyDist: p.nearbyHomeDistance, median: p.originalHomes&&p.originalHomes.searchMedian }); })()" } } ] }The response body starts with the XSSI prefix
{}&&— strip the first 4 bytes in-page beforeJSON.parse(theevaluateabove does this). The residentialproxyis mandatory; each call is an ephemeral session. From the parsed payload read:payload.originalHomes.homes[]— primary results (homes inside the requested region/poly).payload.nearbyHomes.homes[]— expanded radius (populated wheninclude_nearby_homes=trueand the inner result set is sparse).payload.nearbyHomeDistance— miles of the expanded ring (typically 1.0).payload.originalHomes.searchMedian— region medians:{price, sqFt, pricePerSqFt, beds, baths}.payload.originalHomes.gisHomesQueryId— handle for the request (helpful for caching/replay).- Region-wide total count is NOT returned on this endpoint. The "X homes sold matching your criteria" header on the web UI is computed client-side from the returned
homes.lengthwhen< num_homeswas requested. To get an exact total, requestnum_homes=350and check if a 2nd page returns more; iterate until empty. (gis-aggregatesexists but its payload is empty for sold queries — confirmed.)
Decode each home. Every
homes[i]follows the same shape:- Identity:
propertyId(Redfin's stable ID),listingId(per-listing),mlsId.value(MLS#),url(relative path; prependhttps://www.redfin.com). - Location:
streetLine.value,unitNumber.value,city,state,zip,postalCode.value,latLong.value.{latitude,longitude},countryCode. - Sold price + date:
price.value(USD raw int — this is the SOLD price for sold-status homes),pricePerSqFt.value,soldDate(ms epoch — divide by 1000 for seconds, format as ISO).mlsStatusis"Sold" | "Closed" | "Closed Sale";searchStatus=4means sold. - Specs:
beds,baths(decimal —2.5= 2 full + 1 half),fullBaths,sqFt.value,lotSize.value(sqft),yearBuilt.value,stories,propertyType(raw MLS code — varies by source),uiPropertyType(Redfin's normalized bucket: 1=SFR, 2=Condo, 3=Townhouse, 4=Multi-Family, 5=Land, 6/7=Mfd, 8=Co-op). - HOA:
hoa.value(monthly $; absent or{level:1}with novalue⇒ no HOA or undisclosed).isHoaFrequencyKnown. - Photos:
photos.valueis a compact spec like"0-36:2"meaning photos 0–36, format spec:2.numPicturesis the count.photoFormatis"webp"or"jpg". Construct the primary photo URL as:https://ssl.cdn-redfin.com/photo/{dataSourceId}/mbphoto/{last3OfListingId}/genMid.{mlsId}_0.jpg(variants:bigphoto,mbpaddedwide,bcsphoto— bigphoto is highest res).additionalPhotosInfo[]is usually empty in the list view — to get every photo URL, fetch the property page and parse<img>srcs. - Brokerage:
sellingBroker.name,sellingAgent.name,sellingAgent.redfinAgentId,sellingBroker.isRedfin. (For sold-on-Redfin listings only; absent for off-MLS or stale records.) - Days on market:
dom.value(when level≥2 — gated by access level). Emptydom: {level: 1}means Redfin hasn't surfaced it to the caller; fall back to the property page. - Misc:
timeOnRedfin,timeZone,listingTags[](highlight bullets),listingRemarks(MLS description),sashes[](UI badges;sashTypeName=="Bought"confirms a sold-with-Redfin transaction withlastSaleDatepopulated).
- Identity:
List-price-vs-sale delta (when caller requests it) — NOT in the gis JSON. For each home you want this for, open
https://www.redfin.com/{home.url}with a residential-proxybrowserless_agentgoto+evaluate, extractoffers.pricefrom the JSON-LD block — that's the closing/list price displayed on the detail page. The MLS public-record sometimes showsoriginalListPriceseparately in the property's "Sale & Tax History" table (further down the HTML — parse a row labelled "Listed"); subtract fromprice.valuefor the delta. Each property-page load costs roughly the same as one gis call — only do this when truly needed (e.g. when the caller filtered onsold_above_list/sold_below_list/sold_at_list).Paginate when
homes.length === num_homes(likely more results). Bumppage_numberand recomputestart=(page-1)*num_homes. Stop when a page returns fewer thannum_homes.Subject framing. Attach to the response:
{address, latLong, redfinEstimate, lastSale: {price, date}, url}extracted from step 1. If the caller passed lat/lon and you used apoly=search (no propertyId), setsubject.address = nulland report{lat, lon, radius_miles}only.Read-only enforcement. Do NOT call any
/stingray/do/*POST endpoint (Save, Tour, Schedule, Submit Offer). All required data is GET-only. If the caller asks for "comps for [property] + book a tour", refuse the second clause.
Browser fallback
Only when the direct gis-navigation path fails (sustained 403s across the residential proxy pool, or you must resolve a raw street address). Drive it with a residential-proxy browserless_agent — keep the whole flow in ONE call's commands array (it's an ephemeral session, no release step):
- Resolve a raw street address to a propertyId — one
browserless_agentcall, all steps in itscommandsarray:The search input selector is{ "proxy": { "proxy": "residential" }, "commands": [ { "method": "goto", "params": { "url": "https://www.redfin.com/", "waitUntil": "load", "timeout": 45000 } }, { "method": "type", "params": { "selector": "input[name=searchInputBox]", "text": "{street address}" } }, { "method": "waitForSelector", "params": { "selector": "[data-rf-test-name=expanded-row]", "timeout": 10000 } }, { "method": "click", "params": { "selector": "[data-rf-test-name=expanded-row]" } }, { "method": "waitForTimeout", "params": { "time": 2500 } }, { "method": "evaluate", "params": { "content": "(()=>location.href)()" } } ] }input[name=searchInputBox](fall back to the visible search box; confirm via asnapshotcommand if it misses). The finalevaluatereturns the redirected URL, whose trailing/home/{id}segment is the propertyId; then switch back to the direct gis path with{propertyId}. - Read gis via the rendered filter page — a
browserless_agentgototohttps://www.redfin.com/city/{regionId}/{ST}/{City}/filter/property-type=house,min-beds=3,max-beds=4,min-baths=2,min-price=500k,max-price=2M,include=sold-3mo(as the gotourl), then anevaluateto pull the hydrated results out of the page's inline state (or read the property cards directly). Thegisrequest the page fires is the same endpoint as the direct path — once you can reconstruct its URL, switch back to the cheaper direct-navigation gis call.
Site-Specific Gotchas
- XSSI prefix is mandatory to strip: every
/stingray/api/*JSON response is prefixed with the literal bytes{}&&— strip the first 4 bytes beforeJSON.parse. Forgetting this is the #1 cause of "the API returned garbage." status=9alone does NOT restrict to sold homes — even though Redfin's recently-sold UI URL showsstatus=9, the operative param issold_within_days={N}. Without it, the same URL returns ACTIVE listings.statusbecomes a no-op oncesold_within_daysis set; you can passstatus=1or omit it and get the same sold results. Verified during iteration: status=1/2/4/8/16 withsold_within_days=180all returned identical sold-only result sets.market=<metro>is required when usingpoly=. Without it,polyis silently ignored and you get whole-metro homes. Themarketenum is the lowercase metro slug:sanfrancisco,losangeles,seattle,chicago,newyork,boston,dc,dallasfortworth,houston,phoenix,atlanta,miami,portland,denver,philadelphia, etc. You can read it from the property page JSON-LD or from the breadcrumb URL (city URL contains the metro). When you only have aregion_id, omitpolyentirely and the region-based scoping works withoutmarketstrictness.- Param name aliases silently dropped: Redfin's filter page sends
num_beds/max_num_beds/num_baths/max_num_baths/min_parcel_size/max_parcel_size/min_year_built/max_year_built/min_listing_approx_size/max_listing_approx_size. The "natural" aliases (min_beds,max_beds,min_lot_size,min_baths,min_sqft) are accepted by the server with HTTP 200 but silently ignored — you get unfiltered results. Always use the canonical names enumerated in step 4. min_sqftANDmin_listing_approx_sizeare both sent by the official UI for the same min-sqft filter. Either alone works; the UI passes both for redundancy. Same formax_sqft/max_listing_approx_size.- HOA, has-photos, % over/under list, pending toggle are NOT URL filters on the sold endpoint. Redfin's filter page redirects these specs back to a bare URL. To honor these dimensions, filter the result set client-side after fetching.
- Sort key
last-sale-date-descis parsed but a no-op. The response is identical toredfin-recommended-asc. Same forsold-date-desc(rejected as "Invalid arguments") andclosest(rejected). To deliver "Most recent sale" sort, sort the returned homes client-side onsoldDate desc. num_homescaps at 350 per page. Higher values are accepted but the response still returns ≤350.- Pagination requires BOTH
page_numberandstart:page_number=2&start=350(fornum_homes=350). Passing onlypage_numberreturns page 1. region_typeenum: 1=State, 2=ZIP code, 5=County, 6=City, 12=School district. For comps you almost always want 2 (ZIP, tight) or 6 (city, broad). For a ZIP,region_idis the 5-digit ZIP itself (e.g.region_type=2®ion_id=94110); for a city, look up the numeric Redfin city ID from the breadcrumb URL — there is no public city-id resolver beyond the breadcrumb.mpt(map-page-type) matters:mpt=13= sale-search-map (homepage map),mpt=99= filter-page-map (used by/city/.../filter/...pages). Either works for the gis endpoint, butmptmust be present (omitting it returns 0 homes in some configurations). Default tompt=13.uipt(UI Property Type) is the correct property-type filter — NOTpropertyType.propertyTypeis the raw MLS source code (varies wildly by MLS),uiPropertyTypeis Redfin's normalized 1–8 enum. The URL filter isuipt=.- Autocomplete is 403 cookieless:
https://www.redfin.com/stingray/do/location-autocomplete?location=...returns 403 to an unauthenticated (no-cookie) request even through a residential proxy. To resolve a raw street address to a propertyId, the only working path is a full browser session that goes through the search box (see Browser fallback). Always prefer accepting{propertyId, ZIP, or lat+lon}from the caller and skipping address resolution. belowTheFoldis 403 cookieless:https://www.redfin.com/stingray/api/home/details/belowTheFold?...returns 403. Subject-property enrichment beyond JSON-LD requires the rendered property page HTML (parseid="redfin-estimate"div for the Redfin Estimate, parse the "Sale & Tax History" table for prior list/sold prices).soldDateis in milliseconds-since-epoch (not seconds). Divide by 1000 before passing todatetime.fromtimestamp. The value is the closing date (deed-of-trust filing), not the offer-accepted date.mlsStatusvalues for sold listings vary by MLS:"Sold" | "Closed" | "Closed Sale". Treat all three as "sold." UsesearchStatus === 4for a clean integer check.bathsis decimal,fullBathsis an int, but Redfin does not exposehalfBathsdirectly — compute asMath.round((baths - fullBaths) * 2).dom(days on market) is access-gated. For sold homes it's often surfaced only at level≥2 (Redfin sign-in). The cookieless gis path frequently returns{level: 1}(no value). When you need DOM, open the property page and parse the "Sale & Tax History"Days on Marketfield, OR computesoldDate - listDatefrom the same table.- Photo URL pattern by
dataSourceId: dataSourceId 8 (MLSListings/Bay Area) →bigphotoandmbpaddedwidedirectories withML{mlsId}_{N}.jpg. dataSourceId 10 (BAREIS) →mbphoto/bcsphotowithgenMid.{mlsId}_{N}.jpg. Don't hardcode the directory — extract from a sample URL on the property page and use the discovered pattern.numPicturestells you the upper bound on_{N}. listingRemarksaccess-gated: a cookieless gis request may return a truncated description withremarksAccessLevel: 1. Full remarks need the property page.include_nearby_homes=trueexpands the result set when the inner region/poly has few hits —nearbyHomes.homes[]is populated with comps from a wider 1-mile ring. Setinclude_nearby_homes=falseif the caller wants a strict in-region cut.- Rate limit: Redfin's edge throttles non-residential IPs. Even with a residential proxy, keep sustained throughput ≤1 req/sec. Bursts of 5+ same-IP gis requests in <1s reliably trigger 403/captcha. Each
browserless_agentcall is a fresh ephemeral session that draws a new residential IP whenproxy: { proxy: "residential" }is set — single-shot queries are reliable; sustained scraping is not. polyrectangle format:lon1+lat1,lon2+lat2,lon3+lat3,lon4+lat4,lon1+lat1with lon and lat space-separated within each vertex (URL-encoded space =+), and%2Cbetween vertices. The polygon must close (first vertex repeated). Order is longitude first, then latitude — reversing them returns 0 homes silently. Withoutmarket=the param is silently dropped.- Region-wide total count is not on the endpoint.
payload.numHomesOnServer,totalUnclusteredHomes, andoriginalHomesCountare all null in the sold-comps response shape. To emit "X homes matching" you must paginate until exhaustion and sum, or accept "≥N" semantics. gis-aggregatesreturns emptypayload: {}for sold-status queries — don't waste a request on it.- Sale-to-list delta and "% over/under list" are not URL-filterable. If the caller filters on
sold_above_list/sold_below_list/sold_at_list, fetch the raw set (no list/sale filter), then for each home pull its property page JSON-LDoffers.price(list/last price) and computedelta = soldPrice - listPrice; partition client-side. Cost-of-each-extra-property-page is ~1 fetch per comp. - Redfin Estimate is not in any JSON endpoint cookieless — it's only rendered into the property page HTML as
<div class="price smallerFont">$X,XXX,XXX</div>insideid="redfin-estimate". Regex out the dollar string. - MLS rules on photo display: some MLS sources require sign-in to surface listing photos. Cookieless Fetch may return
photos.valuepopulated but the actual CDN URL returns 403 or a placeholder. Check the response when fetching the photo URL itself; fall back to the property-page<img>srcs if needed.
Expected Output
The skill returns a single JSON object framing the subject and the comp set. Distinct outcome shapes:
// 1. Success — propertyId-driven, region-scoped, with comps
{
"success": true,
"subject": {
"input_kind": "redfin_url", // "redfin_url" | "property_id" | "address" | "latlon"
"property_id": 1668106,
"url": "https://www.redfin.com/CA/Milpitas/1966-Yosemite-Dr-95035/home/1668106",
"address": {
"street": "1966 Yosemite Dr",
"unit": null,
"city": "Milpitas",
"state": "CA",
"zip": "95035",
"country": "US"
},
"lat": 37.4302812,
"lon": -121.8691767,
"beds": 4,
"baths": 2.5,
"sqft": 2030,
"year_built": null,
"property_type": "Single-Family",
"redfin_estimate_usd": 1919023,
"last_sale": { "price_usd": 520000, "date": "2000-06-22" }
},
"filters_applied": {
"sold_within_days": 90,
"region_type": 6,
"region_id": 12204,
"market": "sanfrancisco",
"uipt": [1],
"min_price_usd": null,
"max_price_usd": 2000000,
"min_beds": 4,
"max_beds": 4,
"min_baths": null,
"min_sqft": null,
"max_sqft": null,
"min_lot_sqft": null,
"max_lot_sqft": null,
"min_year_built": null,
"max_year_built": null,
"time_on_market_max_days": null,
"sort": "redfin-recommended-asc",
"limit": 20,
"page_number": 1
},
"total_returned": 10,
"page_size": 20,
"more_pages_available": false,
"region_median": {
"sold_price_usd": 1357934,
"sqft": 1568,
"price_per_sqft_usd": 886,
"beds": 3,
"baths": 2.5
},
"nearby_ring_miles": 1.0,
"comps": [
{
"property_id": 551401,
"listing_id": 212753137,
"mls_number": "ML82037320",
"url": "https://www.redfin.com/CA/Milpitas/390-Valmy-St-95035/home/551401",
"address": {
"street": "390 Valmy St",
"unit": null,
"city": "Milpitas",
"state": "CA",
"zip": "95035",
"country": "US"
},
"lat": 37.4550111,
"lon": -121.9032729,
"distance_miles": 1.69,
"sold_price": { "formatted": "$1,500,000", "raw": 1500000, "currency": "USD" },
"sold_date": "2026-03-24",
"list_price_usd": null, // populated only if step 7 was run
"list_to_sale_delta_usd": null,
"list_to_sale_pct": null,
"days_on_market": null, // null when access-gated; fetch property page to backfill
"beds": 3,
"baths": 2.0,
"full_baths": 2,
"half_baths": 0,
"interior_sqft": 1100,
"lot_sqft": 6396,
"lot_acres": 0.147,
"year_built": 1958,
"stories": 1.0,
"property_type": "Single-Family",
"ui_property_type_id": 1,
"hoa_monthly_usd": null,
"price_per_sqft_usd": 1364,
"primary_photo_url": "https://ssl.cdn-redfin.com/photo/8/bigphoto/137/ML82037320_0.jpg",
"photo_count": 37,
"additional_photo_urls": [],
"selling_broker": "Redfin",
"selling_agent": "Karan Kandel",
"selling_broker_is_redfin": true,
"mls_status": "Sold",
"listing_tags": ["MODERN OPEN LAYOUT", "DESIGNER CABINETS", "WATERFALL ISLAND"]
}
// ...up to `limit` more comps
]
}
// 2. Success — lat/lon driven, poly-rectangle scoped, no propertyId
{
"success": true,
"subject": {
"input_kind": "latlon",
"property_id": null,
"url": null,
"address": null,
"lat": 37.4302812,
"lon": -121.8691767,
"radius_miles": 0.5,
"redfin_estimate_usd": null,
"last_sale": null
},
"filters_applied": { /* same shape; region_id/region_type null, poly populated */ },
"comps": [ /* ... */ ]
}
// 3. Empty result set — no homes matched
{ "success": true, "subject": { /*...*/ }, "filters_applied": {/*...*/}, "total_returned": 0, "comps": [] }
// 4. Address resolution failed (raw street address + cookieless gis path)
{ "success": false, "reason": "address_resolution_unavailable",
"detail": "Redfin's autocomplete endpoint is cookie-walled (403 to an unauthenticated request). Pass a Redfin URL, propertyId, ZIP, or lat/lon, or fall back to a residential-proxy browser session through the search box.", "input": "..." }
// 5. Anti-bot wall (sustained 403 across proxy pool)
{ "success": false, "reason": "rate_limited",
"detail": "Redfin returned 403 across N retries with rotating residential proxies. Throttle to ≤1 req/sec, or switch to the browser-fallback path.", "retries": 3 }
// 6. Invalid input (region not found, malformed ID)
{ "success": false, "reason": "subject_not_found", "detail": "...", "input": "..." }