Past InventorySold compsREST APILenders

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=true keeps the listings MarketCheck infers were sold, and expired=true keeps 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:

Response (trimmed)json
{
  "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:

  1. The VIN has dropped out of the latest daily crawl of active listings.
  2. Its most recent listing status is more than 7 days old.
  3. 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

  1. Trade VIN, miles and ZIP
  2. Similar cars sold nearby
  3. Adjust for miles and condition
  4. 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.

recents.tsts
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

  1. Weekly
  2. Sold count by model
  3. Active count by model
  4. Sell-through and days to sell
  5. Buyer adjusts the stocking list

For one make in one market, two facet calls give you a sell-through ratio per model:

  1. Past Inventory Search with make, a scope such as state (or zip and radius), sold=true, last_seen_days=30-*, facets=model|0|50 and rows=0 returns 30-day sold counts by model.
  2. 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.

  1. Monthly
  2. Segments on the book
  3. Metros ranked by sold count
  4. Days-to-sell percentiles per metro
  5. Remarketing assumptions
  6. Credit committee reviews
metro_days.pypython
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.

  1. Monday, after 11:00 UTC
  2. Competitor website domains
  3. Last week's departures, now expired
  4. Same window with sold, a week later
  5. Report by model and price band
  1. Each Monday, call leftTheMarket(domain, '20260316-20260322') for each competitor, with the previous week as the window. source takes the website domain, and owned=true leaves out cars listed on that site but attributed to another dealer.
  2. Group the result by model and by last listed price band, and set it beside your own inventory in the same segments.
  3. 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, latitude with longitude, radius, dealer_id or source.
  • One aggregate per request. One of facets, stats or range_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_range with YYYYMMDD-YYYYMMDD dates 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. rows tops out at 50 and pages move with start. 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. Honor Retry-After, and stop a run when Quota-Remaining reaches 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

  1. Create a free account and copy your API key.
  2. Start on the Free tier for development. Current plans are on the pricing page.
  3. Run soldComps for a car you appraised recently and compare the spread with the number you set.
  4. Keep the Past Inventory Search docs open for the full parameter list.

Start building on the Free tier.

The Free tier is for development and testing, on the same endpoints you will use in production. When you go live, pick a plan on the pricing page.

More use cases

All use cases