The search call behind a car marketplace can also feed its pricing team
Filters, facets, stats, radius and paging on MarketCheck Inventory Search, plus a nightly market snapshot per ZIP and a daily new-arrivals feed built on the same endpoint.
MarketCheck6 min read
For developers building car search for marketplaces, classifieds sites and pricing teams
Inventory Search, GET /v2/search/car/active, is the general-purpose endpoint of the MarketCheck API. It searches active dealer listings in the US and Canada, refreshed daily, and one request can return a page of cars together with value counts and price statistics for everything that matched.
That makes it the obvious engine for a search page and its filter sidebar. The same endpoint also runs jobs no shopper ever sees, and two are worth building early: a nightly market snapshot per ZIP for a pricing team, and a feed of cars that arrived or cut their asking price since yesterday.
In short.
rowssets how many listings come back, and zero is allowed. Everything else in a request either narrows the match (make,price_range,zipwithradiusand many more) or summarizes it (facets,stats). Most surprises come from defaults: one listing per VIN, 10 rows per page, the US market unless you passcountry=ca, and the default sort order, with no error, whensort_bynames a field the API does not know.
What one request returns
The documented sample asks for 2024 Ford F-150s with year=2024, make=ford, model=f-150, start=0, rows=5, facets=trim and stats=price. That is the first five listings, trim counts and price statistics in a single call. Here is the response, trimmed to one listing and the fields discussed below, with the dealer and media blocks removed:
{
"num_found": 23090,
"listings": [
{
"id": "1FTFW3L55RKF33741-28844c8c-23d4",
"vin": "1FTFW3L55RKF33741",
"heading": "New 2024 Ford F-150 XLT",
"price": 51752,
"miles": 12,
"inventory_type": "new",
"dom_active": 248,
"first_seen_at_source_date": "2024-10-19T03:01:59.000Z",
"build": { "year": 2024, "make": "Ford", "model": "F-150", "trim": "XLT", "drivetrain": "4WD" }
}
],
"facets": {
"trim": [
{ "item": "XLT", "count": 14105 },
{ "item": "STX", "count": 4143 },
{ "item": "XL", "count": 1600 }
]
},
"stats": {
"price": { "min": 15995, "max": 264995, "count": 22490, "missing": 600, "mean": 54843.54, "median": 52981.18 }
}
}Four things to read correctly:
num_foundcounts every match, whilelistingsholds only the current page.- A field with no value is left out of the listing object. In the full sample, one used truck arrives with no
mileskey at all. stats.price.countcovers only listings that have a price. In this sample, 600 of the 23,090 matches have none, which is whatmissingreports.headingis the dealer's own text. Another row in the same sample reads "2024 FORD F-150 TRUCK", so build display titles from thebuildblock.
A local search page
- Shopper sets filters
- Your search endpoint
- Inventory Search
- Results grid and sidebar
A marketplace or classifieds results page is one call per change of filters: the shopper's ZIP and radius, their filters, a sort and a page number. Location searches sort nearest first unless you pass sort_by (price, miles, dist, dos_active and more, one field per request), and start plus rows do the paging.
Keep the call on your server, since the key travels as a query parameter. Responses also carry cached photo URLs with your key appended unless you pass append_api_key=false. Show media.photo_links, the dealer's own photo URLs, and serve cached copies through your server if you need them.
const BASE = 'https://api.marketcheck.com/v2/search/car/active'
const PAGE_SIZE = 24 // rows tops out at 50
export interface Filters {
zip: string
radius: number
make?: string
model?: string
bodyType?: string
priceRange?: string // 'min-max', e.g. '1000-50000'
sortBy?: 'price' | 'miles' | 'dist'
page?: number // 0-based
}
export async function searchPage(f: Filters) {
const params = new URLSearchParams({
api_key: process.env.MARKETCHECK_API_KEY ?? '',
zip: f.zip,
radius: String(f.radius),
car_type: 'used',
start: String((f.page ?? 0) * PAGE_SIZE),
rows: String(PAGE_SIZE),
// field|offset|limit|min_count; URLSearchParams encodes each | as %7C
facets: 'body_type|0|20|1,drivetrain|0|10|1',
stats: 'price',
append_api_key: 'false' // keeps the key out of photo URLs
})
const optional: Record<string, string | undefined> = {
make: f.make, model: f.model, body_type: f.bodyType, price_range: f.priceRange, sort_by: f.sortBy
}
for (const [name, value] of Object.entries(optional)) if (value) params.set(name, value)
const res = await fetch(`${BASE}?${params}`)
// 422: the page starts past num_found, or past the paging depth your plan allows
if (res.status === 422) return null
if (!res.ok) throw new Error(`Inventory Search returned ${res.status}`)
const body = await res.json()
return {
total: body.num_found,
lastPage: Math.ceil(body.num_found / PAGE_SIZE) - 1,
listings: body.listings,
facets: body.facets,
stats: body.stats
}
}A filter sidebar that never offers an empty choice
- Current filters
- Same search with facets
- Values with counts
- Shopper picks a value
Facets count values inside the filters you sent. With a minimum count of 1, the last position in field|offset|limit|min_count, every value in the sidebar leads to at least one car, and the same call that fills the grid can fill the sidebar, as the code above does. Two details make it feel right:
- Pass a facet value back exactly as it arrived. Searches are case-insensitive, but the docs recommend taking filter values from facets, in the case the facets use.
- Once a shopper picks a make, the make facet shrinks to that one make. To keep other makes visible for switching, fetch that facet from a second call with
rows=0and every filter except make.
For price and mileage sliders, range_facets returns bucket counts, for example range_facets=price|500|20000|1000. Each bucket includes its lower bound and excludes its upper bound.
What it will not do: list one store's inventory
Pass a dealer parameter such as dealer_id, source or mc_website_id and Inventory Search switches to analytics mode. It forces rows=0 and turns off deduplication, so the response carries counts, facets and stats for that dealer and no listings. That suits a panel comparing one store with its market. A dealer's own inventory pages should come from Dealership Inventory Syndication, /v2/dealerships/inventory, which returns the listings themselves.
Unusual use: a nightly market snapshot per ZIP
A pricing team wants the same few figures every morning for every market it watches: listing counts, asking prices, mileage and days on market. With rows=0, a search returns that summary without a single listing attached.
- Nightly, after 11:00 UTC
- ZIPs and segments
- Search with rows=0 and stats
- Warehouse table
- Pricing team reads trends
The docs say the daily update is published by 11:00 UTC, so schedule the job after that. One call covers one ZIP and one segment, so the nightly call count is the product of your two lists.
import datetime
import os
import time
import requests
BASE = "https://api.marketcheck.com/v2/search/car/active"
KEY = os.environ["MARKETCHECK_API_KEY"]
def get(params):
for attempt in range(4):
r = requests.get(BASE, params={"api_key": KEY, **params}, timeout=60)
if r.status_code != 429:
r.raise_for_status()
return r.json()
# A spent monthly quota does not recover by waiting
if r.headers.get("Quota-Remaining") == "0":
raise RuntimeError("monthly quota exhausted")
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
raise RuntimeError("still rate limited")
def snapshot(zip_code, radius, segment):
body = get({
"zip": zip_code,
"radius": radius,
"car_type": "used",
"rows": 0, # summary only, no listings
"stats": "price,miles,dom_active",
**segment, # e.g. {"make": "Toyota", "model": "RAV4"}
})
stats = body["stats"]
return {
"day": datetime.date.today().isoformat(),
"zip": zip_code,
**segment,
"active_listings": body["num_found"],
"priced_listings": stats["price"].get("count"),
"median_asking_price": stats["price"].get("median"),
"p25_asking_price": stats["price"].get("percentiles", {}).get("25.0"),
"p75_asking_price": stats["price"].get("percentiles", {}).get("75.0"),
"median_miles": stats["miles"].get("median"),
"median_days_active": stats["dom_active"].get("median"),
}
def run(zips, radius, segments):
return [snapshot(z, radius, s) for z in zips for s in segments]A segment can be any filter the search accepts: body_type, year_range, ymmt, or vins with match=year,make,model,trim for cars built like one you stock. Keep one row per day, ZIP and segment, and each trend is a query over that table. Every figure in it comes from asking prices on active listings, so the table describes supply and says nothing about what cars sold for.
Unusual use: what arrived, and what cut its price, since yesterday
- Daily, after 11:00 UTC
- New on a dealer site in the last day
- Price cuts in the last day
- Drop implausible moves
- Morning email
Two filters do the work, and they measure different clocks:
first_seen_at_source_days=1-*finds cars that appeared on a dealer's website in the last 24 hours. The docs choose it overfirst_seen_daysfor new arrivals, sincefirst_seen_daystracks when the listing began, and MarketCheck starts a new listing whenever the price or mileage changes. A listing can be a day old while the car behind it has sat on the lot for weeks.price_change=negativewithfirst_seen_days=1-*finds price cuts. Here the listing clock is the right one, because the cut itself started the new listing, and the pairing keeps out listings whoseref_price(the earlier price at the same source) is stale.
The documented new-arrivals sample shows why the clocks matter. One car first appeared on its dealer's site on Jul 20, 2025, yet its first_seen_at_mc_date is Nov 14, 2020: new to that store, known to the market for years. To find cars MarketCheck has never seen before, filter on first_seen_at_mc_days instead.
Price moves need a sanity check before they reach anyone. In the documented sample for price increases, the top result jumps from 45,950 to 455,003, and the docs describe results like that as corrections of crawl errors. Default deduplication helps the other way: a car syndicated to several sites appears once, under the dealer MarketCheck attributes it to.
Gotchas
- The docs cap
rowsat 50. Ask for more and you get the default page of 10, with no error. - Paging depth depends on your plan. Past that depth, or past
num_found, you get a 422. For bulk extraction, the docs recommend data feeds. - The radius ceiling also depends on your plan, and large radii slow responses. For a whole state, the docs call the
statefilter more efficient than a huge circle. vins(plural) withmatchfinds similar cars, whilevinfinds one car.- Canadian listings need
country=caon every call. nodedup=truereturns every copy of a VIN across sites. Leave it off for search pages and counts, and turn it on when you need every site that carries one car.- A 429 comes from either the per-second rate limit or the monthly quota. The
RateLimit-*andQuota-*headers tell them apart, so honorRetry-Afterand stop whenQuota-Remainingreaches zero. - The data is published once a day, so caching a response until the next update costs little freshness.
Who this is for
Marketplace and classifieds developers can use the search page and sidebar code much as written. Dealer-website vendors can add market context for the stores they host, while each store's own pages come from the syndication endpoint. The snapshot is for pricing and analytics teams, and the arrivals feed suits anyone who sends a morning market email.
Start here
- Start on the Free tier for development. Plans are on the pricing page.
- Run the documented F-150 request with your key and compare
num_found,facetsandstatswith the sample above. - Build the search page, then point the same function at a nightly job with
rows=0. - Keep the Inventory Search reference open for the full parameter list.