Ninety days of cars that left the market, and what they last listed for
Turn the dealer listings that expired or sold in the last 90 days into sold comps, sell-through, metro days-to-sell curves and a weekly read on your competitors' lots.
MarketCheck5 min read
For developers building appraisal, pricing and lending tools
Active inventory shows what dealers are asking today. Appraisers and lenders usually need the other half: what already sold, and for how much. MarketCheck's Past Inventory Search, GET https://api.marketcheck.com/v2/search/car/recents, covers it with the dealer listings that left the market in the past 90 days across the US and Canada, searchable with the same filters, stats and facets as Inventory Search.
Be clear about what the prices mean. A listing's price here is the last price the dealer listed before the car disappeared, and "sold" is MarketCheck's inference from listing activity. Together they make a sold proxy that tracks the market closely without ever recording what the buyer paid, so label it "last listed" anywhere a person will see it.
In short.
sold=truekeeps the listings MarketCheck infers were sold, andexpired=truekeeps cars with no active listing anywhere. Stats and facets on either set give you comps, sell-through and days-to-sell. Every request needs a location or dealer scope, and you can start building on the Free tier.
What a response looks like
The response has the Inventory Search shape: num_found, a listings array, and stats, facets or range_facets when you ask for them. Listings carry the familiar fields, including vin, heading, price, miles, dom_active, dos_active, source and last_seen_at_date.
The documented example asks for the spread of last listed prices on cars like one VIN (matched on year, make, model and trim) that were inferred sold within 50 miles of ZIP 90210 in the last 30 days. Here is that sample, trimmed:
{
"num_found": 15,
"listings": [],
"stats": {
"price": {
"min": 29500,
"max": 36998,
"count": 14,
"missing": 1,
"mean": 33485.5,
"median": 34052.5,
"percentiles": { "25.0": 31991.5, "50.0": 34052.5, "75.0": 34973, "90.0": 35681.5 }
}
}
}Compare count with num_found. In this sample 15 cars matched and 14 had a price; missing holds the one that did not. Show the priced count next to any median you display.
How a car becomes "sold"
MarketCheck marks a VIN sold when all of these hold:
- The VIN has dropped out of the latest daily crawl of active listings.
- Its most recent listing status is more than 7 days old.
- That final listing was the attributed one, meaning the dealer judged to have the car on its lot.
Two consequences for your code:
The sold flag needs 7 days. A car that vanished four days ago is expired and not yet sold. Read the most recent week with expired=true, which keeps listings of VINs that have no active listing anywhere, and switch to sold=true once the window is more than a week old.
Each sale is credited to one dealer. By default you get one listing per VIN. Filter by source or dealer_id and attribution switches off, so that dealer's duplicate listings for a VIN come back as well; add owned=true to keep only the cars attributed to it. nodedup=true goes the other way and returns every listing for each VIN across sources.
Sold comps for an appraisal
- Trade VIN, miles and ZIP
- Similar cars sold nearby
- Adjust for miles and condition
- Appraiser sets the number
The comps call is the documented example plus ten rows: vins with match, a zip and radius, sold=true, last_seen_days=30-* for the last 30 days, and stats=price. Sorting by last_seen descending puts the latest sales first, so the appraiser gets the spread and the cars behind it from one request. Keep the adjustments for miles and condition in your own code, where your appraisers can read and change them.
const RECENTS = 'https://api.marketcheck.com/v2/search/car/recents'
async function recents(params: Record<string, string | number | boolean>) {
const url = new URL(RECENTS)
url.searchParams.set('api_key', process.env.MARKETCHECK_API_KEY ?? '')
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value))
const res = await fetch(url)
// Per-second limit or monthly quota: the Quota-* headers tell you which
if (res.status === 429) throw new Error(`429, retry after ${res.headers.get('Retry-After')} s`)
if (!res.ok) throw new Error(`recents returned ${res.status}`)
return res.json()
}
interface Listing {
vin: string
heading: string
price?: number // last listed price, not a transaction price
miles?: number
dom_active: number
last_seen_at_date: string
}
// Spread of last listed prices on similar cars inferred sold nearby in 30 days
export async function soldComps(vin: string, zip: string, radius = 50) {
const res = await recents({
vins: vin,
match: 'year,make,model,trim',
zip,
radius, // capped at 100 miles on this endpoint
sold: true,
last_seen_days: '30-*',
stats: 'price', // one stats field per request here
sort_by: 'last_seen',
sort_order: 'desc',
rows: 10
})
const price = res.stats?.price
if (!price?.count) return null // no priced comps: say so, do not guess
return {
priced: price.count,
p25: price.percentiles['25.0'],
median: price.median,
p75: price.percentiles['75.0'],
recent: res.listings as Listing[]
}
}
// A competitor's cars last seen in a window (YYYYMMDD-YYYYMMDD) that are now gone, or sold
export async function leftTheMarket(domain: string, lastSeen: string, flag: 'expired' | 'sold' = 'expired') {
const cars: Listing[] = []
for (let start = 0; ; start += 50) {
const page = await recents({
source: domain,
owned: true, // only cars attributed to this dealer
[flag]: true,
last_seen_range: lastSeen,
start,
rows: 50 // the largest page this endpoint returns
})
cars.push(...page.listings)
if (page.listings.length === 0 || start + 50 >= page.num_found) break
}
return cars
}Sell-through by model
- Weekly
- Sold count by model
- Active count by model
- Sell-through and days to sell
- Buyer adjusts the stocking list
For one make in one market, two facet calls give you a sell-through ratio per model:
- Past Inventory Search with
make, a scope such asstate(orzipandradius),sold=true,last_seen_days=30-*,facets=model|0|50androws=0returns 30-day sold counts by model. - Inventory Search,
GET /v2/search/car/active, with the same make, scope and facet returns the count still listed.
Sold divided by sold plus active is the share of the market's recent supply that sold in the window. For the median days to sell, add a stats=dom_active call per model. Past Inventory Search accepts one of facets, stats or range_facets per request, with one field, so the per-model stats are separate requests worth caching for the day.
Days to sell by metro, for a residual desk
A residual or remarketing desk wants to know how long a returned or repossessed car in a given segment sits before it sells, metro by metro, because a national average hides the spread between markets. stats=dom_active on sold cars returns the distribution of active days on market across all sources in one object: min, max, mean, median and the 5th to 99th percentiles. dos_active is the same measure counted on a single site, which suits judging one dealer.
- Monthly
- Segments on the book
- Metros ranked by sold count
- Days-to-sell percentiles per metro
- Remarketing assumptions
- Credit committee reviews
import os
import requests
RECENTS = "https://api.marketcheck.com/v2/search/car/recents"
KEY = os.environ["MARKETCHECK_API_KEY"]
def recents(**params):
r = requests.get(RECENTS, params={"api_key": KEY, **params}, timeout=60)
r.raise_for_status()
return r.json()
def metros(state, segment):
# Metro (MSA) codes in the state, ranked by sold count for the segment
res = recents(state=state, sold="true", facets="msa_code|0|50", rows=0, **segment)
return [bucket["item"] for bucket in res["facets"]["msa_code"]]
def days_to_sell(state, msa_code, segment):
res = recents(state=state, msa_code=msa_code, sold="true",
stats="dom_active", rows=0, **segment)
dom = res["stats"]["dom_active"]
return {"msa_code": msa_code, "sold": dom["count"], "median": dom["median"],
"p75": dom["percentiles"]["75.0"], "p90": dom["percentiles"]["90.0"]}
def metro_table(state, segment, min_sold):
# min_sold is the desk's own floor: below it, a metro's percentiles are too thin to use
rows = [days_to_sell(state, code, segment) for code in metros(state, segment)]
return [row for row in rows if row["sold"] >= min_sold]
segment = {"car_type": "used", "make": "Toyota", "model": "RAV4", "year_range": "2021-2023"}
table = metro_table("TX", segment, min_sold=100)msa_code is not one of the scoping parameters the endpoint requires, so the code pairs it with state. For a metro that crosses a state line, pass both states, since state takes a comma-separated list. Store each month's table too, because the endpoint looks back 90 days and anything longer needs your own history.
What left a competitor's lot this week
Filtering by a competitor's website shows which of its cars left the market in a given week, and a second pass a week later shows which of those MarketCheck inferred as sold.
- Monday, after 11:00 UTC
- Competitor website domains
- Last week's departures, now expired
- Same window with sold, a week later
- Report by model and price band
- Each Monday, call
leftTheMarket(domain, '20260316-20260322')for each competitor, with the previous week as the window.sourcetakes the website domain, andowned=trueleaves out cars listed on that site but attributed to another dealer. - Group the result by model and by last listed price band, and set it beside your own inventory in the same segments.
- A week later, run the same window with
'sold'. Cars on the first list and missing from the second left the market without an inferred sale.
Limits to design around
- Scope every request. Include at least one of
city,state,zip,latitudewithlongitude,radius,dealer_idorsource. - One aggregate per request. One of
facets,statsorrange_facets, with one field. - Radius. The smaller of 100 miles and your plan's maximum.
- The sold lag. Read the latest week with
expired=true. - Day windows. The documented examples write
30-*for the last 30 days. For fixed weeks,last_seen_rangewithYYYYMMDD-YYYYMMDDdates leaves no room for doubt. - Ninety days of history. Persist what you compute if you need longer trends.
- A proxy to test. Last listed prices and inferred sales can be checked against your own closed deals. Run that comparison before a pricing model depends on them.
- Daily refresh. New data is published by 11:00 AM UTC, the same time as Inventory Search. Schedule after that and cache results for the day.
- Paging.
rowstops out at 50 and pages move withstart. Going past your plan's pagination depth returns a 422, so narrow the filters instead of paging deeper. - Two kinds of 429.
RateLimit-*headers describe the per-second limit, which clears within a second.Quota-*headers describe the monthly quota. HonorRetry-After, and stop a run whenQuota-Remainingreaches 0.
Who this is for
Developers building appraisal and trade-in tools, stocking and buying tools for dealer groups, residual and remarketing models at lenders, and market research that needs the sold side of the market next to the asking side.
Start here
- Create a free account and copy your API key.
- Start on the Free tier for development. Current plans are on the pricing page.
- Run
soldCompsfor a car you appraised recently and compare the spread with the number you set. - Keep the Past Inventory Search docs open for the full parameter list.