Inventory SearchREST APIMarketplacesPricing

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. rows sets how many listings come back, and zero is allowed. Everything else in a request either narrows the match (make, price_range, zip with radius and 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 pass country=ca, and the default sort order, with no error, when sort_by names 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:

Response (trimmed)json
{
  "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_found counts every match, while listings holds 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 miles key at all.
  • stats.price.count covers only listings that have a price. In this sample, 600 of the 23,090 matches have none, which is what missing reports.
  • heading is the dealer's own text. Another row in the same sample reads "2024 FORD F-150 TRUCK", so build display titles from the build block.

A local search page

  1. Shopper sets filters
  2. Your search endpoint
  3. Inventory Search
  4. 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.

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

  1. Current filters
  2. Same search with facets
  3. Values with counts
  4. 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=0 and 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.

  1. Nightly, after 11:00 UTC
  2. ZIPs and segments
  3. Search with rows=0 and stats
  4. Warehouse table
  5. 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.

market_snapshot.pypython
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

  1. Daily, after 11:00 UTC
  2. New on a dealer site in the last day
  3. Price cuts in the last day
  4. Drop implausible moves
  5. 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 over first_seen_days for new arrivals, since first_seen_days tracks 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=negative with first_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 whose ref_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 rows at 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 state filter more efficient than a huge circle.
  • vins (plural) with match finds similar cars, while vin finds one car.
  • Canadian listings need country=ca on every call.
  • nodedup=true returns 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-* and Quota-* headers tell them apart, so honor Retry-After and stop when Quota-Remaining reaches 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

  1. Start on the Free tier for development. Plans are on the pricing page.
  2. Run the documented F-150 request with your key and compare num_found, facets and stats with the sample above.
  3. Build the search page, then point the same function at a nightly job with rows=0.
  4. Keep the Inventory Search reference 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