Auto-CompleteTaxonomySearch UXREST API

Stop showing shoppers trims that return zero cars

Typeahead and cascading pickers on MarketCheck Auto-Complete and the taxonomy endpoints, plus a matcher that maps a DMS export's messy trim strings onto clean values.

MarketCheck5 min read

For front-end and data developers building car search forms and inventory tools

A search box that offers "F-150 Lightning" in a town with none for sale sends the shopper to an empty results page. The fix is to draw every suggestion and every dropdown value from a source that matches the question being asked, and MarketCheck has two:

  • Auto-Complete, GET /v2/search/car/auto-complete, suggests values from active dealer listings. It takes Inventory Search style filters, so suggestions can be scoped to a state or a ZIP and radius, and to new or used stock. It can also return a listing count per suggestion.
  • Taxonomy Auto-Complete, GET /v2/specs/car/auto-complete, and Taxonomy Facets, GET /v2/specs/car/terms, return values from MarketCheck's vehicle specifications database, whether or not anything is for sale.

Pick by the question. A shopper looking for a car to buy needs Auto-Complete, while someone describing a car that already exists needs the taxonomy: a trade-in form, a service booking, a sell-my-car flow or a data job cleaning up another system's records.

In short. Inventory questions go to Auto-Complete, and "what exists" questions go to the taxonomy endpoints. Both auto-complete endpoints take field and input and return at most 50 terms, ranked by match quality. On the inventory side, term_counts=true adds a listing count to each term.

What a suggestion response looks like

The documented sample asks Auto-Complete for Ford models starting with "f-" in California, with counts: field=model, input=f-, make=Ford, state=CA and term_counts=true. Trimmed to the first five terms:

Response (trimmed)json
{
  "terms": [
    { "item": "F-150", "count": 25687 },
    { "item": "F-150 Heritage", "count": 11 },
    { "item": "F-150 Lightning", "count": 2556 },
    { "item": "F-250", "count": 26 },
    { "item": "F-250 Super Duty", "count": 8795 }
  ]
}

Two details in this sample shape the UI. Order follows match quality rather than stock: every term here is a prefix match, and F-150 Heritage, with 11 listings, sits ahead of F-150 Lightning, with 2,556. To lead with the best-stocked models, re-sort by count on your side, because the docs say suggestions cannot be sorted manually. Thin entries, such as the 26-listing F-250 beside F-250 Super Duty, are what facet_min_count hides.

A search box that suggests only what is for sale

  1. Shopper types
  2. Debounce and cache
  3. Auto-Complete
  4. Shopper picks a term
  5. Inventory Search

Send the request from your own backend after a short debounce, so the key stays off the page and each prefix is fetched once. Scope it with whatever the shopper has already told you: zip and radius or state, car_type, a chosen make and a year. Matching runs prefix first, then case-insensitive, then substring, which is why the documented sample for makes with input=ac returns Acura, Cadillac, Maybach and Pontiac. Split prefix hits from substring hits on your side if the mix reads oddly.

For a combined year, make and model box, ask for field=ymm or field=mm. Inventory Search takes those composite strings through its own ymm and mm parameters (the docs' examples are 2019|Toyota|Camry and toyota|camry), and the mm description says to pass the value exactly as returned.

suggest.tsts
const BASE = 'https://api.marketcheck.com/v2'
const KEY = process.env.MARKETCHECK_API_KEY ?? ''

interface Term { item: string, count: number }
interface Scope { zip?: string, radius?: number, state?: string, car_type?: 'new' | 'used' | 'certified', make?: string }

const cache = new Map<string, Term[]>() // clear once a day, after the listing refresh

async function get(path: string, params: Record<string, string | number | undefined>) {
  const url = new URL(BASE + path)
  url.searchParams.set('api_key', KEY)
  for (const [name, value] of Object.entries(params)) {
    if (value !== undefined && value !== '') url.searchParams.set(name, String(value))
  }
  const res = await fetch(url)
  if (!res.ok) throw new Error(`${path} returned ${res.status}`)
  return res.json()
}

// Typeahead: your /suggest route calls this after the browser debounces
export async function suggest(field: 'make' | 'model' | 'mm' | 'ymm', input: string, scope: Scope = {}) {
  const text = input.trim()
  if (text.length < 2) return []
  const key = JSON.stringify([field, text.toLowerCase(), scope])
  const cached = cache.get(key)
  if (cached) return cached

  const body = await get('/search/car/auto-complete', {
    field, input: text, term_counts: 'true', facet_min_count: 3, ...scope
  })
  // Merge case variants such as "san diego" and "San Diego", keeping the API's order
  const merged = new Map<string, Term>()
  for (const term of (body.terms ?? []) as Term[]) {
    const seen = merged.get(term.item.toLowerCase())
    if (seen) seen.count += term.count
    else merged.set(term.item.toLowerCase(), { ...term })
  }
  const terms = [...merged.values()]
  cache.set(key, terms)
  return terms
}

// Cascading pickers: the next level's values, counted from live listings near the shopper
export async function pickerOptions(
  level: 'make' | 'model' | 'trim',
  chosen: { year?: string, make?: string, model?: string },
  zip: string,
  radius: number
) {
  const body = await get('/search/car/active', {
    ...chosen, zip, radius, rows: 0, facets: `${level}|0|1000|1`, facet_sort: 'index'
  })
  return (body.facets?.[level] ?? []) as Term[]
}

Cascading pickers with no dead options

  1. Pick a year
  2. Makes with stock
  3. Pick a make
  4. Models with stock
  5. Pick a model
  6. Results page

Dropdowns are a facet problem. For a search form, each level is one Inventory Search call with rows=0, the choices made so far, the shopper's location and facets=<next level>|0|1000|1. Every option that comes back has at least one listing in range, and facet_sort=index sorts the values alphabetically for a dropdown. pickerOptions above is that call.

For a form where someone describes a car they own, switch to Taxonomy Facets. /v2/specs/car/terms?field=model&make=Ford&year=2021 lists Ford models in the specs database for that year, keyed by field name and sorted alphabetically. It returns 20 values per field by default, so append |offset|limit to the field, with a limit of up to 1000, to get the rest.

The taxonomy includes spelling variants. In the documented Taxonomy Auto-Complete sample of every trim for a Ford F-150, LARIAT and Lariat both appear, and so do HARLEY DAVIDSON, HARLEY-DAVIDSON, Harley Davidson and Harley-Davidson. Collapse variants for display with a normalized key, and keep the raw strings for storage.

Unusual use: mapping a DMS export's trim strings onto clean values

Dealer management systems hold trims that people typed, with abbreviations, packages run into the trim, stray punctuation and random capitals. Any report grouped by trim splits on them: a string like XLT SPRCRW 4X4 and a plain XLT are the same trim to a buyer and different rows to a GROUP BY. The taxonomy gives you a target list for each year, make and model, and normalization collapses its spelling variants.

  1. DMS export rows
  2. Trim list per model year
  3. Normalize and match
  4. Review the leftovers
  5. Mapping table

If a row carries a VIN that decodes, take its trim from the NeoVIN decoder (/v2/decode/car/neovin/{vin}/specs) instead, since the build beats any typed string. The matcher below is for rows where the typed trim is all you have. It uses the documented way to list every value of a field: Taxonomy Auto-Complete with an empty input and the known filters. That endpoint also stops at 50 terms, which is one reason the matcher filters by year, and a list of exactly 50 may be incomplete.

normalize_trims.pypython
import os
import re
from functools import lru_cache

import requests

BASE = "https://api.marketcheck.com/v2"
KEY = os.environ["MARKETCHECK_API_KEY"]


@lru_cache(maxsize=None)
def canonical_trims(year, make, model):
    r = requests.get(f"{BASE}/specs/car/auto-complete", params={
        "api_key": KEY, "field": "trim", "input": "",
        "year": year, "make": make, "model": model,
    }, timeout=30)
    r.raise_for_status()
    return tuple(r.json().get("terms", []))  # an empty answer can be {} with no terms key


def norm(text):
    return re.sub(r"[^A-Z0-9]+", " ", text.upper()).strip()


def match_trim(year, make, model, dms_trim):
    candidates = canonical_trims(year, make, model)
    typed = norm(dms_trim)
    exact = [t for t in candidates if norm(t) == typed]
    if exact:
        return exact[0], "exact"
    # A canonical trim fits when every one of its words appears in the typed string
    words = set(typed.split())
    fits = sorted((t for t in candidates if norm(t) and set(norm(t).split()) <= words),
                  key=lambda t: len(norm(t).split()), reverse=True)
    if fits:
        best = fits[0]
        rival = next((t for t in fits[1:] if norm(t) != norm(best)), None)
        if rival is None or len(norm(rival).split()) < len(norm(best).split()):
            return best, "contained"
    return None, "review"

Run it over the distinct year, make, model and trim combinations in the export. The trim list is cached per model year, so the call count is the number of distinct year, make and model triples, and each typed string is matched once. Exact and contained matches go straight into the mapping table. Everything else, such as LTD for Limited, goes to a person, and their answers land in the same table so the next export picks them up.

The containment rule is conservative on purpose. Edit distance would pair XL with XLT, one letter apart and different trims, so a canonical trim wins here only when all of its words appear in the typed string and no different trim of equal length also fits. Loosen the rule only after you have read what lands in review.

Gotchas

  • Both auto-complete endpoints need field and input. To list every value on the taxonomy side, send an empty input with filters, as the F-150 trims sample does.
  • Suggestions stop at 50 terms and cannot be re-sorted by the API.
  • term_counts=true changes each term from a string to an { item, count } object, so parse for the flag you sent.
  • An empty answer can arrive as {}, with no terms key at all, as the documented taxonomy sample for field=engine&input=3. on a Ford F-150 shows.
  • Case variants appear on the inventory side too: the documented city sample for input=san in California returns both san diego and San Diego. Searches are case-insensitive, so one merged suggestion finds both.
  • Auto-Complete follows the market. It defaults to country=us, and a model that sells out locally drops out of its suggestions while staying in the taxonomy.
  • Every keystroke you forward is a call. Debounce in the browser and cache per prefix on the server, clearing the cache when the daily listing update lands.

Who this is for

Front-end developers on marketplaces and dealer websites can take the search box and the pickers as written, and the same taxonomy calls can back trade-in and sell-my-car forms that must accept any car in the specs database. Data engineers loading DMS or CRM exports into a warehouse get the trim matcher.

Start here

  1. Start on the Free tier for development. Plans are on the pricing page.
  2. Send field=make&input=to to Auto-Complete, then to Taxonomy Auto-Complete, and compare the two answers.
  3. Wire the typeahead through your backend with a debounce and a cache.
  4. Run the matcher on one extract of distinct trims from your DMS before you trust it with the whole file.
  5. Keep the references open: Auto-Complete, Taxonomy Auto-Complete and Taxonomy Facets.

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