๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now โ†’
Guides

How to Scrape Redfin Data: Listings, Prices, Market

Learn how to scrape Redfin data: pull listings, prices, and property details from Redfin's Stingray JSON API and CSV export, and handle its rate limits.

S SparkProxy 2 28 min read
Share

To scrape Redfin data at scale, you don't fight HTML at all. Redfin hands you almost everything through an internal JSON API and a one-click CSV export, and the real work is knowing where those endpoints live, how to read them, and how to stay under the rate limits without tripping Cloudflare. This guide stays practical. You'll see exactly where Redfin keeps listings, prices, property details, and its own Redfin Estimate, how to hit the Stingray gis endpoint for clean listing JSON, how the gis-csv download shortcut works, why every response starts with a {}&& prefix you must strip, how to beat the 350-home cap, and working Python against SparkProxy's Scraping API. It's the Redfin-specific companion to our general guide on scraping real estate listings and a close cousin of the Zillow data guide, so here we go deep on what makes Redfin its own animal.

What you'll build

  • A field map for price, beds, baths, sqft, lot size, status, and coordinates
  • A region resolver that turns a city name into Redfin's region_id and region_type
  • Direct calls to Redfin's Stingray gis endpoint that return listing JSON, no HTML
  • A one-line CSV export using the same gis-csv endpoint the "Download All" button hits
  • A price-band sweep that beats Redfin's 350-home cap, plus the Redfin Estimate via the AVM endpoint

Scrape responsibly: public data, ToS, and the Redfin Estimate caveat

Set the boundary before any code. Collecting listing data that any anonymous visitor can see on a public Redfin page sits on defensible ground, and US courts have repeatedly declined to treat access to public web pages as unauthorized access. That is not blanket permission. A few rules keep you on the right side:

  • Public pages only. Don't touch anything behind a login, a saved-search account, or Redfin's agent tooling. Bypassing authentication is a different legal category entirely.
  • Read the Terms of Use as a signal. Redfin's terms prohibit automated collection, and its listings are MLS-sourced under IDX and RESO licensing with republication limits. That rarely creates criminal exposure for public data, but it tells you to keep a small footprint and go slow.
  • The Redfin Estimate is an estimate, not a fact. It's Redfin's proprietary valuation model with a published median error that is low for on-market homes and noticeably higher for off-market ones. Store it as a signal, label it clearly, and never present it as an appraisal or a sale price.
  • Don't harvest agent PII in bulk or rebuild the portal. Using price and inventory data for internal market analysis is a very different risk profile from scraping agent contact details or republishing MLS-sourced listings as a competing site.

The rest of this article assumes you're collecting public listing data for internal analysis. For the broader legal framework on MLS licensing and Fair Housing when scraped data feeds automated decisions, see the compliance section of our real estate data aggregation guide. For anything you plan to publish or resell, get legal sign-off first.


What a Redfin listing exposes

Decide your schema up front. Retrofitting a field after you've pulled 100,000 homes means re-scraping. Here's the field reference most Redfin property data pipelines converge on. Almost every field below comes straight from the gis JSON, so you rarely need to open a detail page for the basics.

FieldData typeExampleNotes
`propertyId`integer`147012345`Redfin's stable property id. Your primary key across every endpoint.
`listingId`integer`191234567`Per-listing id. Changes when a home relists. Needed for the AVM and detail calls.
`mlsId`string`OC24123456`The source MLS number. Useful for dedup against MLS feeds.
`price`integer`849000`Current list price. Wrapped as `{"value": 849000}` in the JSON.
`beds` / `baths`int / float`3` / `2.5`Keep the decimal on baths. Half baths matter.
`sqFt`integer`1840`Interior square feet, wrapped as `{"value": ...}`. Not the lot.
`lotSize`integer`6098`Lot square footage, wrapped. Missing on many condos.
`yearBuilt`integer`1998`Wrapped. Missing on some new construction and land.
`propertyType` / `uipt`int`1`House, condo, townhouse, and so on. See the `uipt` codes below.
`mlsStatus`string`Active``Active`, `Pending`, `Contingent`, `Sold`. Drives whether the row is current.
`daysOnMarket`integer`12`How long the listing has been live. Strong demand signal.
`soldDate`timestamp`1717027200000`Epoch millis, present on sold rows.
`latLong`object`{lat, lng}`Exact coordinates, straight from the JSON. No geocoding needed.
`url`string`/CA/Irvine/...`Relative detail path. Prefix with `https://www.redfin.com`.

Two of these carry more weight than the rest. Days on market plus the price and any sold history together read seller motivation in a way the sticker price alone never will. A home sitting at 60 days in a market where the median is 14 is a different negotiation than a three-day-old listing at the same price. Pair those with the Redfin Estimate from the AVM endpoint and you have a modeled value anchor next to the asking price on every property.

The uipt (user-interface property type) codes are worth pinning down, because they double as both a returned field and a search filter:

`uipt`Property type
`1`House
`2`Condo / co-op
`3`Townhouse
`4`Multi-family
`5`Land
`6`Other
`7`Manufactured
`8`Mobile / trailer

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Where Redfin keeps its data: the Stingray API and the {}&& prefix

Here's the thing that separates Redfin from Zillow. Redfin doesn't hide its data inside a hydration blob you have to reverse-engineer. It exposes a semi-public internal API under https://www.redfin.com/stingray/, and the same endpoints the site's own frontend calls will answer you directly. That's the whole game: skip the rendered page, call the JSON.

There's one catch that stops most first-timers cold. Every Stingray response is prefixed with the four characters {}&& before the real JSON begins. A raw body looks like this:

{}&&{"version":3,"errorMessage":"Success","resultCode":0,"payload":{ ... }}

That prefix is an anti-JSON-hijacking guard (XSSI protection). If you pipe the body straight into json.loads, it throws on character one. Strip it first, splitting on the && and parsing what follows:

import json

def strip_prefix(text):
    """Redfin guards its JSON with a {}&& XSSI prefix. Remove it before parsing."""
    return json.loads(text.split("&&", 1)[1])

Splitting with maxsplit=1 only cuts the first &&, so any && inside the JSON payload is safe. Once you're past the prefix, the shape is consistent: resultCode is 0 on success, and everything you want lives under payload. This single helper is used by every function below. It's also the number-one silent failure in DIY Redfin scrapers, so it earns its own line.

The endpoints you'll actually use:

EndpointReturnsFormat
`/stingray/do/location-autocomplete`Region ids for a place name`{}&&` JSON
`/stingray/api/gis`Listing search results`{}&&` JSON
`/stingray/api/gis-csv`The same search as a CSV fileraw CSV, no prefix
`/stingray/api/home/details/avm`The Redfin Estimate for one home`{}&&` JSON
`/stingray/api/home/details/belowTheFold`Price history, tax, schools`{}&&` JSON

The same JSON-endpoint-first mindset powers our broader playbook on scraping hidden JSON API endpoints; Redfin is close to the ideal case for it.


Quickstart: one call through the Scraping API

Because the Stingray endpoints return JSON and CSV, you do not need a headless browser for the core job. What you do need is IP rotation and the right request headers, since Redfin sits behind Cloudflare and rate-limits per IP. A managed Scraping API handles proxy rotation and anti-bot behind one call, so you send a URL and get back the raw body. If you want the build-versus-buy math, see web scraping API vs self-managed proxies.

The base endpoint is https://scrape.sparkproxy.io/api/v1 and it authenticates with an X-API-Key header. A single call to the autocomplete endpoint, routed through a US IP, no rendering:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: YOUR_API_KEY" \
  --data-urlencode "url=https://www.redfin.com/stingray/do/location-autocomplete?location=Irvine&v=2" \
  --data-urlencode "render_js=false" \
  --data-urlencode "country_code=US" \
  --data-urlencode "forward_headers={\"Referer\":\"https://www.redfin.com/\",\"Accept\":\"application/json\"}"

The same thing in Python, where the rest of this guide lives:

import requests

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"          # from your SparkProxy dashboard

def api_get(url, render=False, premium=False):
    resp = requests.get(
        API,
        headers={"X-API-Key": API_KEY},
        params={
            "url": url,
            "render_js": "true" if render else "false",   # Stingray returns JSON, no JS needed
            "country_code": "US",                          # US-only inventory, pin a US exit IP
            "premium_proxy": "true" if premium else "false",
            "forward_headers": json.dumps({
                "Referer": "https://www.redfin.com/",
                "Accept": "application/json",
            }),
        },
        timeout=60,
    )
    resp.raise_for_status()
    return resp.text

Two parameters matter most here. render_js=false keeps this cheap, because there's nothing to render: the endpoint answers with data, not a page. The forward_headers Referer is what makes the request look like a real XHR from redfin.com rather than a bare hit that Cloudflare flags on sight.


The gis search endpoint: region_id, region_type, and clean JSON

Scraping one home is the easy 5%. The real job is finding every listing in an area, and the gis endpoint is how Redfin's own map does it. First you turn a place name into a region. The autocomplete endpoint returns rows whose id field is formatted {region_type}_{region_id}, so you get both numbers from one string:

from urllib.parse import quote, urlencode

def resolve_region(place):
    """Turn 'Irvine, CA' into Redfin's region_type + region_id via autocomplete."""
    url = f"https://www.redfin.com/stingray/do/location-autocomplete?location={quote(place)}&v=2"
    data = strip_prefix(api_get(url))
    for section in data["payload"]["sections"]:
        for row in section.get("rows", []):
            rid = row.get("id", "")          # e.g. "6_16163" => type 6 (city), id 16163
            if "_" in rid:
                rtype, region_id = rid.split("_", 1)
                return {"name": row.get("name"), "region_type": int(rtype),
                        "region_id": region_id}
    return None

Parsing the id field is deliberate. It means you never hardcode a region, and you get the correct region_type for free. The type codes you'll meet most often:

`region_type`Region kind
`2`ZIP code
`6`City
`1`Neighborhood
`5`County
`4`State

Treat 2 (ZIP) and 6 (city) as the reliable ones to build on, and resolve the rest through autocomplete rather than memorizing them, since Redfin can renumber internal codes.

Now the search itself. The gis endpoint takes a stack of filter parameters. The reliable way to get a correct filter set is to configure the search you want on redfin.com in a browser, then copy the resulting gis query from the network tab. The core parameters:

ParameterWhat it isExample
`region_id` / `region_type`The area to search`16163` / `6`
`num_homes`Max homes to return (cap is 350)`350`
`uipt`Property-type filter (see codes above)`1,2,3,4,5,6,7,8`
`sf`Status filter (for-sale, coming-soon, etc.)`1,2,3,5,6,7`
`status`Listing-status bitmask`9`
`ord`Sort order`redfin-recommended-asc`
`min_price` / `max_price`Price band`500000` / `750000`
`v`Stingray API version`8`

Build the URL and parse the homes. Redfin wraps many scalar fields as {"value": X}, so a small unwrap helper saves you a hundred .get("value") calls:

def gis_url(region_id, region_type, min_price=None, max_price=None, num_homes=350):
    params = {
        "al": 1, "num_homes": num_homes, "ord": "redfin-recommended-asc",
        "region_id": region_id, "region_type": region_type,
        "sf": "1,2,3,5,6,7", "status": 9, "uipt": "1,2,3,4,5,6,7,8", "v": 8,
    }
    if min_price is not None: params["min_price"] = min_price
    if max_price is not None: params["max_price"] = max_price
    return "https://www.redfin.com/stingray/api/gis?" + urlencode(params)

def unwrap(field):
    """Redfin wraps many values as {'value': X}. Return the scalar."""
    return field.get("value") if isinstance(field, dict) else field

def parse_home(h):
    ll = unwrap(h.get("latLong")) or {}
    return {
        "property_id": h.get("propertyId"),
        "listing_id":  h.get("listingId"),
        "mls_id":      unwrap(h.get("mlsId")),
        "price":       unwrap(h.get("price")),
        "beds":        h.get("beds"),
        "baths":       h.get("baths"),
        "sqft":        unwrap(h.get("sqFt")),
        "lot_size":    unwrap(h.get("lotSize")),
        "year_built":  unwrap(h.get("yearBuilt")),
        "street":      unwrap(h.get("streetLine")),
        "city":        h.get("city"),
        "state":       h.get("state"),
        "zip":         h.get("zip"),
        "status":      h.get("mlsStatus"),
        "dom":         h.get("daysOnMarket"),
        "lat":         ll.get("latitude"),
        "lng":         ll.get("longitude"),
        "url":         "https://www.redfin.com" + (h.get("url") or ""),
    }

def search_region(region_id, region_type, min_price=None, max_price=None):
    data = strip_prefix(api_get(gis_url(region_id, region_type, min_price, max_price)))
    homes = data.get("payload", {}).get("homes", [])
    return [parse_home(h) for h in homes]

One call gives you up to 350 clean listings with price, beds, baths, status, days on market, and coordinates, and zero HTML parsing. If a gis call returns a non-zero resultCode, the usual cause is a region that needs its market slug (Redfin groups regions into markets like socal or seattle); the autocomplete row for that region carries the market, so add it as a market= parameter and retry.


The download-CSV shortcut: gis-csv

Redfin has a "Download All" link on every search page, and it hits an endpoint almost nobody scrapes on purpose. Swap gis for gis-csv with the exact same parameters, and instead of JSON you get a ready-made CSV, no {}&& prefix, columns already labeled:

def download_csv(region_id, region_type, out="redfin.csv", min_price=None, max_price=None):
    params = {
        "al": 1, "num_homes": 350, "ord": "redfin-recommended-asc",
        "region_id": region_id, "region_type": region_type,
        "sf": "1,2,3,5,6,7", "status": 9, "uipt": "1,2,3,4,5,6,7,8", "v": 8,
    }
    if min_price is not None: params["min_price"] = min_price
    if max_price is not None: params["max_price"] = max_price
    url = "https://www.redfin.com/stingray/api/gis-csv?" + urlencode(params)
    text = api_get(url)          # gis-csv returns raw CSV, so no strip_prefix
    with open(out, "w", newline="", encoding="utf-8") as f:
        f.write(text)

The header row Redfin returns includes SALE TYPE, SOLD DATE, PROPERTY TYPE, ADDRESS, CITY, STATE OR PROVINCE, ZIP OR POSTAL CODE, PRICE, BEDS, BATHS, SQUARE FEET, LOT SIZE, YEAR BUILT, DAYS ON MARKET, $/SQUARE FEET, HOA/MONTH, STATUS, LATITUDE, LONGITUDE, and the listing URL. For a quick market snapshot, this is the fastest path in the whole guide: one request, a spreadsheet-ready file. The one thing it shares with gis is the 350-row ceiling, so the coverage trick in the next section applies to both.


Beating Redfin's 350-home cap

Now the wall. A single gis or gis-csv query returns at most 350 homes, no matter how many actually match. There's no page 2 that gets you to 351: the cap is on the whole result set. A dense city can hold several thousand active listings, so one query sees a slice.

The fix is not more requests to the same query, it's narrower queries whose union covers everything. Two knobs get you there:

  • Price bands. Split the price axis into ranges and run one search per band. Set min_price and max_price so each band returns under 350.
  • ZIP subdivision. If a single price band still pins 350 in a hot city, drop from the city region to its ZIP-code regions (region_type 2) and search each ZIP. Resolve ZIP regions the same way, through autocomplete.

Price bands are usually enough and cheaper, so start there and only fall back to ZIPs for bands that still overflow. Dedupe by propertyId, because bands and ZIP tiles overlap at the edges:

def sweep_city(region_id, region_type, bands):
    """Union listings across price bands; dedupe by propertyId."""
    seen = {}
    for lo, hi in bands:
        homes = search_region(region_id, region_type, min_price=lo, max_price=hi)
        for h in homes:
            if h["property_id"]:
                seen[h["property_id"]] = h
        # a band that returns the full 350 is likely truncated; narrow it further
        if len(homes) >= 350:
            print(f"  band {lo}-{hi} hit the cap; subdivide by ZIP or split the band")
    return list(seen.values())

CITY_BANDS = [(0, 500_000), (500_000, 750_000),
              (750_000, 1_200_000), (1_200_000, None)]

The len(homes) >= 350 check is your truncation alarm. When a band returns exactly the cap, assume you're missing homes and either split that band in half or descend to ZIP-level searches for it. This banded sweep is the map cousin of the "slice the query" trick from our general real estate scraping guide, and on Redfin it's the difference between a partial sample and the whole city.


Property details and the Redfin Estimate (AVM)

The gis JSON already carries the fields most pipelines need, so you only open a home when you want the extras: the Redfin Estimate, full price history, tax records, and school ratings. Those live under /stingray/api/home/details/, keyed by the propertyId and listingId you already collected. The Redfin Estimate comes from the avm endpoint:

def redfin_estimate(property_id, listing_id):
    url = ("https://www.redfin.com/stingray/api/home/details/avm"
           f"?propertyId={property_id}&listingId={listing_id}&accessLevel=1")
    data = strip_prefix(api_get(url))
    p = data.get("payload", {})
    return {
        "estimate": p.get("predictedValue"),
        "low":      p.get("predictedValueLow"),
        "high":     p.get("predictedValueHigh"),
    }

Key names inside the avm and belowTheFold payloads are release-dependent, so archive one raw blob during development and diff it when something moves. When a key name shifts, you inspect the saved blob, find the new name, and change a string. That's a five-minute fix, not a rewrite. The same discipline applies to price history and tax records under belowTheFold: walk the payload once against a saved sample, note the paths, and pin them.

Treat the Redfin Estimate the way you'd treat any model output. It's present on off-market homes too, which is exactly why it's worth pulling, but it carries a published error that widens off-market. Store it with its date, label it as an estimate, and pair it with the actual list price and any sold history so you have independent value anchors rather than one number pretending to be the truth.


Rate limits and staying unblocked

Redfin is friendlier than Zillow here. There's no "Press and Hold" behavioral wall like PerimeterX. What you face instead is Cloudflare in front of the site plus per-IP rate limiting on the Stingray endpoints. Push too fast from one IP and you get an HTTP 429, or a Cloudflare interstitial served with a 403 and a challenge page in the body instead of JSON. Because there's no heavy behavioral fingerprinting on the JSON endpoints, datacenter IPs clear far more often than they do on Zillow, so start cheap and escalate only what gets blocked.

The core discipline is rate, not just IP quality. Even with rotation, hammering gis a few times a second from one exit looks nothing like a person browsing a map. Hold each IP to a modest rate, add jitter, and forward a Referer so the request reads as a real XHR. Map symptoms to fixes so you're not guessing mid-run:

SymptomLikely causeFix
`json.loads` fails on char 1Forgot to strip the `{}&&` prefixUse `strip_prefix` before parsing
HTML / "Pardon Our Interruption" bodyCloudflare challenge on a flagged IPEscalate to `premium_proxy=true`; slow down
`403` on `gis` from datacenterIP range flagged, or thin headersResidential IP plus `forward_headers` Referer
`429` after N requestsPer-IP rate too highWiden delays, add jitter, lean on rotation
`resultCode` non-zero, empty homesRegion needs its `market` slugAdd `market=` from the autocomplete row
Exactly 350 homes returnedHit the result capSplit by price band or ZIP, dedupe by `propertyId`

Our full playbook is how to avoid getting your proxy blocked. The insight most guides skip: let the block response decide when to escalate, instead of paying for residential everywhere. Send gis and gis-csv on datacenter first, and flip premium_proxy on only for the exact requests that come back as a challenge. On a healthy Redfin run, most requests never leave the cheap tier.


A complete production Redfin scraper

Here's the whole thing wired together: a session, a request helper with retry, backoff, and datacenter-to-residential escalation, the region resolver, the banded sweep, an optional Redfin Estimate lookup, and a runner that writes clean rows to CSV.

import requests, json, time, csv, random
from urllib.parse import quote, urlencode

API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"          # from your SparkProxy dashboard

session = requests.Session()
session.headers.update({"X-API-Key": API_KEY})

def strip_prefix(text):
    return json.loads(text.split("&&", 1)[1])

def unwrap(field):
    return field.get("value") if isinstance(field, dict) else field

def api_get(url, premium=False):
    """One call with retry, backoff, and datacenter->residential escalation."""
    params = {
        "url": url, "render_js": "false", "country_code": "US",
        "premium_proxy": "true" if premium else "false",
        "forward_headers": json.dumps({
            "Referer": "https://www.redfin.com/",
            "Accept": "application/json",
        }),
    }
    for attempt in range(4):
        try:
            r = session.get(API, params=params, timeout=60)
        except requests.RequestException:
            time.sleep(2 ** attempt); continue
        challenged = r.status_code in (403, 429) or "Pardon Our Interruption" in r.text[:2000]
        if r.status_code == 200 and not challenged:
            return r.text
        if challenged and params["premium_proxy"] == "false":
            params["premium_proxy"] = "true"          # escalate this request only
        time.sleep((2 ** attempt) + random.uniform(0, 1.5))
    return None

# ---- region + search ----

def resolve_region(place):
    url = f"https://www.redfin.com/stingray/do/location-autocomplete?location={quote(place)}&v=2"
    text = api_get(url)
    if not text:
        return None
    for section in strip_prefix(text)["payload"]["sections"]:
        for row in section.get("rows", []):
            rid = row.get("id", "")
            if "_" in rid:
                rtype, region_id = rid.split("_", 1)
                return {"name": row.get("name"), "region_type": int(rtype),
                        "region_id": region_id}
    return None

def gis_url(region_id, region_type, lo=None, hi=None):
    params = {"al": 1, "num_homes": 350, "ord": "redfin-recommended-asc",
              "region_id": region_id, "region_type": region_type,
              "sf": "1,2,3,5,6,7", "status": 9,
              "uipt": "1,2,3,4,5,6,7,8", "v": 8}
    if lo is not None: params["min_price"] = lo
    if hi is not None: params["max_price"] = hi
    return "https://www.redfin.com/stingray/api/gis?" + urlencode(params)

def parse_home(h):
    ll = unwrap(h.get("latLong")) or {}
    return {
        "property_id": h.get("propertyId"), "listing_id": h.get("listingId"),
        "price": unwrap(h.get("price")), "beds": h.get("beds"),
        "baths": h.get("baths"), "sqft": unwrap(h.get("sqFt")),
        "lot_size": unwrap(h.get("lotSize")), "year_built": unwrap(h.get("yearBuilt")),
        "street": unwrap(h.get("streetLine")), "city": h.get("city"),
        "state": h.get("state"), "zip": h.get("zip"),
        "status": h.get("mlsStatus"), "dom": h.get("daysOnMarket"),
        "lat": ll.get("latitude"), "lng": ll.get("longitude"),
        "url": "https://www.redfin.com" + (h.get("url") or ""),
    }

def search_region(region_id, region_type, lo=None, hi=None):
    text = api_get(gis_url(region_id, region_type, lo, hi))
    if not text:
        return []
    try:
        homes = strip_prefix(text).get("payload", {}).get("homes", [])
    except (KeyError, IndexError, json.JSONDecodeError):
        return []
    return [parse_home(h) for h in homes]

def sweep_city(region_id, region_type, bands):
    seen = {}
    for lo, hi in bands:
        homes = search_region(region_id, region_type, lo, hi)
        for h in homes:
            if h["property_id"]:
                seen[h["property_id"]] = h
        if len(homes) >= 350:
            print(f"  band {lo}-{hi} hit the 350 cap; narrow it or drop to ZIPs")
        time.sleep(random.uniform(1.5, 3.5))          # conservative per-IP pacing
    return list(seen.values())

# ---- optional: Redfin Estimate per home ----

def redfin_estimate(property_id, listing_id):
    url = ("https://www.redfin.com/stingray/api/home/details/avm"
           f"?propertyId={property_id}&listingId={listing_id}&accessLevel=1")
    text = api_get(url)
    if not text:
        return None
    try:
        return strip_prefix(text).get("payload", {}).get("predictedValue")
    except json.JSONDecodeError:
        return None

def run(place, bands, out="redfin.csv", with_estimate=False):
    region = resolve_region(place)
    if not region:
        print(f"Could not resolve region for {place!r}"); return
    print(f"Region: {region['name']} (type {region['region_type']}, id {region['region_id']})")
    listings = sweep_city(region["region_id"], region["region_type"], bands)
    print(f"Found {len(listings)} unique listings")

    cols = ["property_id", "price", "beds", "baths", "sqft", "lot_size",
            "year_built", "street", "city", "state", "zip", "status",
            "dom", "lat", "lng", "url"]
    if with_estimate:
        cols.insert(2, "redfin_estimate")

    with open(out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
        w.writeheader()
        for i, row in enumerate(listings, 1):
            if with_estimate and row["listing_id"]:
                row["redfin_estimate"] = redfin_estimate(row["property_id"], row["listing_id"])
                time.sleep(random.uniform(1.0, 2.5))
            w.writerow(row)
            if i % 50 == 0:
                print(f"  {i}/{len(listings)} written")

if __name__ == "__main__":
    BANDS = [(0, 500_000), (500_000, 750_000),
             (750_000, 1_200_000), (1_200_000, None)]
    run("Irvine, CA", BANDS, out="irvine.csv", with_estimate=False)

This survives the failure modes that stop naive scrapers. It strips the {}&& prefix everywhere, resolves the region instead of hardcoding ids, unwraps Redfin's {"value": ...} fields, detects the Cloudflare challenge in the body rather than trusting the status code alone, escalates only blocked requests to residential, dedupes by propertyId, treats a full 350 return as a truncation warning, and paces itself. Swap the place and price bands for your market and it runs.


Cost per request: the JSON endpoints are cheap

The Scraping API bills in credits, and Redfin is one of the cheapest large portals to run precisely because you never render JavaScript. Every Stingray call is a plain data fetch, so your default mode is the 1-credit tier, and you only pay more on the requests Cloudflare actually challenges.

Request modeCreditsUse it for
Rotating datacenter, no JS1`gis`, `gis-csv`, autocomplete, and `avm`: your default for everything
Premium (residential), no JS10The exact requests that come back as a Cloudflare challenge
Add-on: `stealth`, `country_code`+5 eachLayer only when a target requires it

The math at scale: discovering every listing in a city of 4,000 active homes takes maybe a dozen banded gis calls, so listing discovery costs on the order of a dozen credits, not thousands. Even pulling the Redfin Estimate for all 4,000 homes runs at the 1-credit datacenter rate unless a subset gets challenged. Compare that with a portal that forces JavaScript rendering and residential IPs on every request, where the same job runs 25 credits a page. Redfin's open JSON and CSV endpoints are the reason a full-city pull here is cheap. Keep the escalation ladder in place and the handful of challenged requests are the only ones that touch the 10-credit tier.


Frequently asked questions

FAQ

Scraping publicly displayed listing data that any anonymous visitor can view is generally defensible under current US case law, which has declined to treat access to public web pages as unauthorized access. The limits come from elsewhere: don't log in or bypass authentication, don't harvest agent personal data in bulk, and don't republish MLS-sourced listings as a competing portal. Redfin's terms prohibit automated collection, so the safest footing is internal analysis of public data, and you should get legal sign-off before publishing or reselling anything you collect.

Redfin has no official public API, but its website runs on a semi-public internal API under https://www.redfin.com/stingray/. The same endpoints the frontend calls (gis for search, gis-csv for the CSV export, home/details/avm for the Redfin Estimate) will answer direct requests and return structured JSON or CSV, which is why you rarely need to parse rendered HTML.

Every Stingray JSON response begins with the four characters {}&& before the real payload. It's an anti-JSON-hijacking guard, and it will break json.loads on the first character if you don't remove it. Strip it by splitting on the first && and parsing what follows, for example json.loads(text.split("&&", 1)[1]). The gis-csv endpoint is the exception: it returns raw CSV with no prefix.

Call the gis-csv endpoint with the same parameters as a gis search: https://www.redfin.com/stingray/api/gis-csv?region_id=...®ion_type=...&num_homes=350&v=8. It returns a ready-made CSV, the same file the site's "Download All" button produces, with labeled columns for price, beds, baths, square feet, status, coordinates, and the listing URL. It shares the 350-row cap with the JSON endpoint, so split large areas by price band or ZIP.

A single gis or gis-csv query returns at most 350 homes, regardless of how many match, and there's no pagination past that ceiling. To cover a city that holds thousands of listings, split the search by price bands, and if a band still returns the full 350, drop to ZIP-code regions (region_type 2) and search each ZIP. Dedupe by propertyId because bands and ZIP tiles overlap at the edges.

Not for most of it. Redfin has no behavioral wall like Zillow's PerimeterX, so its JSON and CSV endpoints often clear on rotating datacenter IPs when you forward a Referer header and keep a sane request rate. The site sits behind Cloudflare and rate-limits per IP, so route only the requests that come back as a 403 or 429 challenge through residential (premium) proxies, and keep everything else on the cheap datacenter tier.


Limited-time ยท 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon โ€” claim it before it's gone

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates proxy infrastructure and a managed Scraping API used for large-scale web data collection, including PropTech and real estate valuation pipelines. We run datacenter and residential proxy pools with country-level geo-targeting, headless rendering, and anti-bot handling built for the rate limits and Cloudflare defenses portals like Redfin enforce. The code and configuration in this guide reflect how we and our customers collect public listing data in production. Explore the SparkProxy Scraping API docs to start building.

Keep reading

Related articles

How to Set Up and Use a Proxy in Postman

How to Set Up and Use a Proxy in Postman

Set up a proxy in Postman the right way: custom proxy host and port, proxy auth, SSL cert fixes, verify the exit IP in the Console, plus Newman env vars.

SparkProxyยทGuides
How to Scrape Yahoo Finance Data (2026 Guide)

How to Scrape Yahoo Finance Data (2026 Guide)

Scrape Yahoo Finance quotes, historical prices, and fundamentals from its hidden JSON API. Crumb and cookie setup, 429 fixes, Python code, and the legal rules.

SparkProxyยทGuides