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

How to Scrape CarGurus Data: Listings, Prices, Dealers

Scrape CarGurus data at scale: pull listings, prices, dealer info, and deal ratings from the internal search JSON endpoint, with working Python code.

S SparkProxy 2 16 min read
Share
How to Scrape CarGurus Data: Listings, Prices, Dealers

You can scrape CarGurus data without a browser farm or brittle HTML parsing, because CarGurus builds its search results from a single internal JSON endpoint. Hit that endpoint the right way and you get clean records: year, make, model, trim, price, mileage, dealer, and the one field nobody else exposes, CarGurus' own Deal Rating and Instant Market Value. This guide walks through the endpoint, its parameters, the anti-bot reality, and a working Python pipeline built on the SparkProxy Scraping API.

Why scrape CarGurus

CarGurus lists millions of used, new, and certified pre-owned vehicles across US, UK, and Canadian dealers. For anyone tracking automotive prices, the draw is not the raw listings. Zillow-style data is everywhere. The draw is CarGurus' pricing intelligence.

Every listing carries a Deal Rating (Great Deal, Good Deal, Fair Deal, and so on) that CarGurus computes by comparing the asking price against its Instant Market Value (IMV), a per-car fair-price estimate. That IMV is a modeled benchmark. If you scrape it, you get the output of CarGurus' own pricing model for free, instead of building a comparable-vehicle model yourself from scratch.

Concrete use cases:

  • Dealers benchmarking their inventory against the local market and spotting overpriced competitors.
  • Wholesalers and flippers hunting Great Deal cars the moment they list.
  • Market analysts tracking depreciation curves, days-on-market, and price drops by make and region.
  • Lenders and insurers validating collateral values against a live market signal.

The unique angle in this guide: instead of rendering pages with Selenium, you talk to the same JSON endpoint the site's own front end calls. It is faster, cheaper, and returns richer fields than the visible HTML.

What you can extract

A single listing object from the search endpoint typically exposes the fields below. Names change over time, so always inspect the live response, but this is the shape you will work with.

Field (typical JSON key)Example valueNotes
`id``412839920`Stable listing identifier
`vin``1HGCV1F3XLA012345`Present on many, not all, records
`carYear` / `makeName` / `modelName` / `trimName``2021` / `Honda` / `Accord` / `Sport`Core vehicle identity
`price``24990`Asking price in dollars
`mileage``31450`Odometer reading
`dealRating``GOOD_PRICE`CarGurus rating enum (see below)
`expectedPrice``26300`The IMV fair-price estimate
`savingsRecommendation``1310`Dollars below IMV
`daysOnMarket``18`How long the car has listed
`serviceProviderName``Downtown Honda`Dealer name
`sellerCity` / `sellerRegion``Austin` / `TX`Location
`phoneNumber``512-555-0142`Dealer contact (business)
`inventoryType``DEALER` or `FSBO`Dealer vs for-sale-by-owner
`carType``USED`, `NEW`, `CERTIFIED`Condition class

Price, mileage, and location you can get from many sources. The dealRating plus expectedPrice pair is what makes CarGurus worth the effort.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

How CarGurus serves its listings

Open any CarGurus used-car search, such as https://www.cargurus.com/Cars/spt_used_cars, and watch the page load. The visible results are not in the initial HTML. The browser runs a small request against an internal endpoint that returns the listings as JSON, then renders them client side.

The backend behaves like a classic server-rendered search API. A search is registered and assigned a searchId, and the front end then fetches pages of results for that search. In the Network tab you will see action names along the lines of getListingsForSearchId or ajaxFetchSubsetInventoryListing.action. Both return the same style of JSON payload: an array of listing objects plus paging metadata.

This is the key insight. You do not need to render anything. You call the JSON endpoint directly, pass the right query parameters, and read structured data. It is the same technique covered in our guide to scraping hidden JSON API endpoints, applied to a high-value automotive target.

Finding the endpoint and its parameters

Do this once, by hand, to capture the exact request your target market uses:

  1. Open the used-car search page for your ZIP.
  2. Open DevTools (F12), go to the Network tab, and filter by Fetch/XHR.
  3. Scroll or page the results. Watch for a request that returns JSON with an array of cars.
  4. Right-click it and copy the full URL and headers. That is your template.

The parameters that matter most:

ParameterExamplePurpose
`zip``78701`Origin ZIP for the search
`distance``100`Search radius in miles
`entitySelectingHelper.selectedEntity`model entity codeNarrow to a make/model; omit for all
`sortType``DEAL_SCORE`Ordering; `DEAL_SCORE` returns best deals first
`sortDir``ASC`Sort direction
`startingIndex``0`Pagination offset
`maxResults``20`Page size
`inventorySearchWidgetType``AUTO`Widget context the site expects

Two params do the heavy lifting for coverage: zip and distance. CarGurus caps how deep a single search paginates, so you will not pull an entire national inventory from one ZIP. You sweep a set of ZIP codes with an overlapping radius, then dedupe. More on that below.

Deal Rating and Instant Market Value

This is the field set that competitors' scraping tutorials ignore. The Instant Market Value (expectedPrice) is CarGurus' estimate of what a given car should sell for, based on its year, trim, mileage, options, and regional market. The Deal Rating (dealRating) is derived by comparing the actual asking price to that IMV.

The rating enum maps to the labels you see on the site:

CarGurus labelJSON value (typical)Meaning
Great Deal`GREAT_PRICE`Price well below IMV
Good Deal`GOOD_PRICE`Price below IMV
Fair Deal`FAIR_PRICE`Price near IMV
High Priced`HIGH_PRICE`Price above IMV
Overpriced`OVERPRICED`Price well above IMV
No Price Analysis`NO_ANALYSIS`Too few comparables to rate

Two practical notes. First, NO_ANALYSIS is common on brand-new models or rare trims where CarGurus lacks comps. Keep those rows and flag them rather than dropping them. Second, if you snapshot price, expectedPrice, and daysOnMarket daily, you can reconstruct how a dealer's pricing moves relative to market over the life of a listing. That time series is more valuable than any single scrape.

Dealer vs private-seller records

Most CarGurus inventory comes from franchised and independent dealers. A smaller slice is private, sold directly by owners. The inventoryType field separates them, usually DEALER versus FSBO (for sale by owner).

The distinction matters for both data quality and ethics:

  • Dealer records carry a business name, a lot address, and a sales line. This is commercial data, and it is the bulk of what you want for market analysis.
  • Private-seller records may expose an individual's contact details. Treat these as personal data. If your project does not specifically need them, filter FSBO rows out at ingest and never store the contact fields.

If you are building a dealer-competitive dataset, filtering to inventoryType == "DEALER" also cleans up your comps, since private sales price differently from dealer lots.

The anti-bot reality

CarGurus does not hand out its data freely. The site sits behind bot management and rate limiting. If you fire requests at the JSON endpoint from a datacenter IP with a bare requests client, you will quickly see one of three things: an HTTP 403, an interstitial challenge page returned instead of JSON, or an empty listings array.

What triggers blocks:

  • Datacenter IP ranges that the site's protection recognizes.
  • Missing or stale cookies and a missing or invalid searchId.
  • Too many requests, too fast, from one address.
  • Unrealistic headers that do not look like a real browser session.

The fix is to route requests through residential IPs with a real browser fingerprint and to keep your pace reasonable. Rather than assembling that stack yourself, the SparkProxy Scraping API handles the proxy rotation, TLS fingerprint, and header realism for you. If you would rather understand the underlying challenge first, our write-up on bypassing Cloudflare when scraping covers the mechanics.

Scraping CarGurus with SparkProxy

The SparkProxy Scraping API takes a target URL, fetches it through a managed proxy and browser layer, and returns the response body. The base URL is https://scrape.sparkproxy.io/api/v1, and you authenticate with an X-API-Key header. Three parameters carry the load for CarGurus:

  • render_js controls headless Chromium rendering. For a raw JSON endpoint, set it to false. You are not rendering a page, so JS execution wastes credits and can corrupt the JSON. For the HTML search page, set it to true.
  • premium_proxy=true routes through residential IPs, which CarGurus tolerates far better than datacenter ranges.
  • country_code=us pins the exit location. CarGurus is region-aware, so a US car search should exit from a US IP (gb or ca for those markets).

Here is a small client wrapper:

import requests

API = "https://scrape.sparkproxy.io/api/v1"
KEY = "YOUR_API_KEY"

def spark_get(target_url, render_js=False):
    """Fetch target_url through SparkProxy and return the response."""
    resp = requests.get(
        API,
        headers={"X-API-Key": KEY},
        params={
            "url": target_url,
            "render_js": str(render_js).lower(),
            "premium_proxy": "true",
            "country_code": "us",
        },
        timeout=120,
    )
    resp.raise_for_status()
    return resp

Now call the listings endpoint. Build the CarGurus URL with its own query string, then hand the whole thing to SparkProxy as the url value. The requests library URL-encodes it for you:

from urllib.parse import urlencode

LISTINGS = "https://www.cargurus.com/Cars/inventorylisting/ajaxFetchSubsetInventoryListing.action"

def fetch_page(zip_code, offset=0, page_size=20, entity=None, radius=100):
    params = {
        "zip": zip_code,
        "distance": radius,
        "sortType": "DEAL_SCORE",
        "sortDir": "ASC",
        "inventorySearchWidgetType": "AUTO",
        "startingIndex": offset,
        "maxResults": page_size,
        "sourceContext": "carGurusHomePageModel",
    }
    if entity:
        params["entitySelectingHelper.selectedEntity"] = entity
    target = LISTINGS + "?" + urlencode(params)
    return spark_get(target, render_js=False).json()

The response wraps listings in an array. The key is sometimes listings and sometimes results, so read defensively and normalize each record into a flat row:

def parse_listing(item):
    return {
        "id": item.get("id"),
        "vin": item.get("vin"),
        "year": item.get("carYear"),
        "make": item.get("makeName"),
        "model": item.get("modelName"),
        "trim": item.get("trimName"),
        "price": item.get("price"),
        "mileage": item.get("mileage"),
        "deal_rating": item.get("dealRating"),
        "imv": item.get("expectedPrice"),
        "savings": item.get("savingsRecommendation"),
        "dealer": item.get("serviceProviderName"),
        "city": item.get("sellerCity"),
        "region": item.get("sellerRegion"),
        "seller_type": item.get("inventoryType"),
        "days_on_market": item.get("daysOnMarket"),
    }

If the JSON path ever breaks, SparkProxy's extract_rules gives you a fallback that reads the rendered HTML search page instead:

import json

def fetch_html_cards(search_url):
    resp = requests.get(
        API,
        headers={"X-API-Key": KEY},
        params={
            "url": search_url,
            "render_js": "true",
            "premium_proxy": "true",
            "country_code": "us",
            "extract_rules": json.dumps({
                "cards": {
                    "selector": "div[data-cg-ft='car-blade']",
                    "type": "list",
                    "output": {
                        "title": "h4",
                        "price": "[data-cg-ft='car-blade-price']",
                        "deal": "[data-testid='deal-badge']",
                    },
                }
            }),
        },
        timeout=180,
    )
    resp.raise_for_status()
    return resp.json()

Selectors are illustrative. Confirm them against the live DOM, since CarGurus changes class and data attributes periodically.

Pagination, ZIP, and radius at scale

One search will not return an entire region. You page through results until the array comes back empty or you hit the per-search cap, then move to the next ZIP. Add a short sleep so you stay polite, and wrap requests in retry-with-backoff so a single challenge page does not kill the run. The pattern is the same one in our guide to retry and backoff strategies.

import time

def spark_get_retry(target_url, render_js=False, tries=4):
    for i in range(tries):
        try:
            resp = spark_get(target_url, render_js=render_js)
            if resp.status_code == 200 and resp.text.strip():
                return resp
        except requests.RequestException:
            pass
        time.sleep(2 ** i)  # 1s, 2s, 4s, 8s
    raise RuntimeError(f"failed after {tries} tries: {target_url}")

def scrape_zip(zip_code, radius=100, max_records=1000, page_size=20):
    rows, offset = [], 0
    while offset < max_records:
        data = fetch_page(zip_code, offset=offset, page_size=page_size, radius=radius)
        listings = data.get("listings") or data.get("results") or []
        if not listings:
            break
        rows.extend(parse_listing(x) for x in listings)
        offset += page_size
        time.sleep(1.5)
    return rows

To build a national picture, sweep seed ZIPs across major metros with an overlapping radius, then dedupe by VIN (or id when the VIN is missing), because a car near a metro edge shows up in two searches:

import csv

SEED_ZIPS = ["10001", "90001", "60601", "77001", "33101", "98101"]  # NYC, LA, CHI, HOU, MIA, SEA

def build_dataset(zips, radius=100):
    seen, out = set(), []
    for z in zips:
        for row in scrape_zip(z, radius=radius):
            key = row["vin"] or row["id"]
            if not key or key in seen:
                continue
            seen.add(key)
            out.append(row)
    return out

rows = build_dataset(SEED_ZIPS)
with open("cargurus.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=rows[0].keys())
    writer.writeheader()
    writer.writerows(rows)
print(f"{len(rows)} unique listings")

For denser coverage, use a larger ZIP list with a smaller radius so the overlapping circles blanket the country without leaving gaps. The tradeoff is more requests for fewer duplicates.

Common errors and fixes

SymptomLikely causeFix
HTTP 403 or a challenge HTML pageDatacenter IP, missing cookiesSet `premium_proxy=true` and `country_code=us`
`JSONDecodeError` on parseEndpoint returned HTML, not JSONCheck status, retry with backoff, confirm `render_js=false`
Empty `listings` arrayOffset past the cap or stale paramsCap `startingIndex`, sweep more ZIPs, re-copy a fresh request
Same cars across ZIPsOverlapping search radiiDedupe by `vin`, then `id`
`dealRating` is `NO_ANALYSIS`New or rare model, thin compsExpected. Keep the row and flag it
Prices look staleCached resultsRefresh daily and store snapshots

A last piece of advice: snapshot rather than overwrite. Store each day's price, expectedPrice, dealRating, and daysOnMarket with a timestamp. A single scrape tells you the market today. A month of snapshots tells you how fast cars depreciate, how long good deals last, and which dealers chase the market down. That history is the real asset. If used cars are only one slice of your project, the same JSON-endpoint approach powers our guides to scraping real estate listings and scraping Facebook Marketplace.

Frequently asked questions

FAQ

Scraping publicly visible pages is legally contested, and CarGurus' Terms of Use prohibit automated access. Collecting public, non-personal data (dealer listings and business contacts) while respecting rate limits is more defensible than harvesting private-seller personal details. This is not legal advice, so confirm your use case with a lawyer.

No. CarGurus does not publish an official public API for developers. The practical route to CarGurus data is the internal search JSON endpoint that the site's own front end calls, which you can reach through a scraping API that handles proxies and anti-bot measures.

The Deal Rating (Great Deal, Good Deal, Fair Deal, High Priced, Overpriced) is CarGurus' label comparing a car's asking price to its Instant Market Value. Both the dealRating label and the expectedPrice IMV appear in the listing JSON, so yes, you can scrape them alongside price and mileage.

In practice, yes. CarGurus blocks most datacenter IP ranges and returns challenge pages instead of data. Routing through residential IPs with premium_proxy=true and a matching country_code on the SparkProxy Scraping API avoids the bulk of those blocks.

A single ZIP search paginates only so deep before results stop. To build a large dataset of car listings, sweep a set of ZIP codes with an overlapping radius and dedupe by VIN. That defeats the per-search cap and covers a region or the whole country.

The VIN is present on many listings but not all. Dealer name, city, and a business phone number are commonly available and count as commercial data. Private-seller (FSBO) contact details are personal data, so filter those out unless your project has a specific, lawful reason to keep them.

Limited-time ยท 50% off

Get 50% off your first month

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 SparkProxy's datacenter proxies, residential proxies, and the SparkProxy Scraping API. We work with web scraping and data-collection infrastructure every day, from reverse-engineering internal JSON endpoints to defeating bot management at scale. Our guides are written by the same engineers who run the network, and every code example uses SparkProxy's own documented API so you can reproduce it directly. Explore the full Scraping API documentation to get started.

Keep reading

Related articles

How to Scrape Alibaba Product Data

How to Scrape Alibaba Product Data

Scrape Alibaba product data at B2B depth: parse ladder pricing, normalize MOQ units, read supplier trust badges, and pivot rows from products to suppliers.

SparkProxyยทGuides
How to Detect When Your Scraper Is Blocked

How to Detect When Your Scraper Is Blocked

Detect when your scraper is blocked, including silent HTTP 200 soft blocks: baselines, selector contracts, canary URLs, and per-IP success rates.

SparkProxyยทGuides