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.

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 value | Notes |
|---|---|---|
| `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.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Legal and ethical ground rules
Read this before you write a single request. CarGurus' Terms of Use prohibit automated access and scraping, and this guide is not legal advice. Scraping publicly visible pages sits in a contested area of law, so a few rules keep you on defensible ground:
- Only collect public, non-personal data. Dealer names, addresses, and business phone numbers are commercial info. Private-seller (
FSBO) records can contain a real person's contact details. Do not harvest or store that personal data. - Respect
robots.txtand rate limits. Do not hammer the site. A polite crawler that requests a few pages per second causes no harm and draws no attention. - Do not republish CarGurus' IMV as your own product without checking your rights. Using it internally for analysis is one thing. Reselling it is another.
- Cache and refresh sensibly. You rarely need the same car twice in an hour.
When in doubt, consult a lawyer who knows the CFAA and the relevant case law on public-data scraping. Treat the data as something you are borrowing, not something you own.
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:
- Open the used-car search page for your ZIP.
- Open DevTools (F12), go to the Network tab, and filter by Fetch/XHR.
- Scroll or page the results. Watch for a request that returns JSON with an array of cars.
- Right-click it and copy the full URL and headers. That is your template.
The parameters that matter most:
| Parameter | Example | Purpose |
|---|---|---|
| `zip` | `78701` | Origin ZIP for the search |
| `distance` | `100` | Search radius in miles |
| `entitySelectingHelper.selectedEntity` | model entity code | Narrow 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 label | JSON 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
FSBOrows 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_jscontrols headless Chromium rendering. For a raw JSON endpoint, set it tofalse. You are not rendering a page, so JS execution wastes credits and can corrupt the JSON. For the HTML search page, set it totrue.premium_proxy=trueroutes through residential IPs, which CarGurus tolerates far better than datacenter ranges.country_code=uspins the exit location. CarGurus is region-aware, so a US car search should exit from a US IP (gborcafor 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
| Symptom | Likely cause | Fix |
|---|---|---|
| HTTP 403 or a challenge HTML page | Datacenter IP, missing cookies | Set `premium_proxy=true` and `country_code=us` |
| `JSONDecodeError` on parse | Endpoint returned HTML, not JSON | Check status, retry with backoff, confirm `render_js=false` |
| Empty `listings` array | Offset past the cap or stale params | Cap `startingIndex`, sweep more ZIPs, re-copy a fresh request |
| Same cars across ZIPs | Overlapping search radii | Dedupe by `vin`, then `id` |
| `dealRating` is `NO_ANALYSIS` | New or rare model, thin comps | Expected. Keep the row and flag it |
| Prices look stale | Cached results | Refresh 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.
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
Related articles

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.

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.

How to Bypass GeeTest CAPTCHA: Avoidance Over Solvers
Bypass GeeTest CAPTCHA by never triggering it: what moves the score, how to fix IP, TLS and session signals, and why back-off outlasts every solver.
