Documentation

Field references, integration guides, and product docs. Pick a product to dive in.

Vehicle Listings Data

What cars are for sale right now, and who is selling them. Search, look up by VIN, or pull market stats. Get a key and pay per call.

Endpoints22
ProtocolREST · JSON
AuthBearer key
BillingPer call
Base URLhttps://luxe.hermesdata.io/v1_2/listings

Overview

Vehicle Listings tells you what cars are for sale right now, and who is selling them. It is a read-only REST API over used and new vehicle inventory advertised by dealerships across the US and Canada. Ask it three kinds of question: search the catalog with filters, look up one vehicle by id or VIN, or get summary stats for a market. This version also tells you which dealership each car belongs to, and keeps records for about 90 days after a car leaves the market - so you can see what it sold for and how its price moved.

1
Get a key

Create one API key in your portal. It's your bearer token for every request.

2
Ask a question

Search and filter, look up by id or VIN, or pull dealer inventory and market stats.

3
Pay per call

No subscription. Every request spends a small amount from your credit wallet. See Pricing.

Quickstart

Two steps. Create an API key on the API key page, then run the request below. It searches live inventory and returns the matching listings in data, with the match count and paging info in meta.

GET /v1_2/listings/search?make=Toyota&model=RAV4&year_min=2023&page_size=3

A few recent RAV4s. Every search works the same way: add your key, filter with query params, then read the results in data - each row carrying the website it came from and, where we know it, the dealership behind it.

Request
curl "https://luxe.hermesdata.io/v1_2/listings/search?make=Toyota&model=RAV4&year_min=2023&page_size=3" \
  -H "Authorization: Bearer hd_live_your_key_here"
Response · 200 application/json
{
  "data": [
    {
      "id": "v_77c1e0",
      "vin": "2T3P1RFV8SC123456",
      "make_name": "Toyota",
      "model_name": "RAV4",
      "trim_name": "XLE",
      "year": 2025,
      "sale_price": 34210,
      "odometer": 8,
      "province_state_code": "TX",
      "city": "Austin",
      "dealer_netloc": "exampletoyota.com",
      "dealer_site_id": "7d24c0e918b6a53f42910cd7e6b38f5a",
      "dealer_id": "d_44b19e",
      "entity_dealer_id": "d_44b19e",
      "new_vehicle": true,
      "first_seen": "2026-07-24"
    },
    {
      "id": "v_5ab902",
      "make_name": "Toyota",
      "model_name": "RAV4",
      "trim_name": "Limited",
      "year": 2024,
      "sale_price": 31995,
      "odometer": 12480,
      "province_state_code": "TX",
      "city": "Dallas",
      "dealer_netloc": "northsideauto.example",
      "dealer_site_id": "e05b6c37a91d4f28b3c7069e5a1d82f4",
      "certified": true,
      "first_seen": "2026-07-11"
    }
  ],
  "meta": { "page": 1, "page_size": 3, "total": 862, "next_cursor": "eyJ2IjoiMDgzMTk2…" }
}
// Two rows, two states of dealer identity: the first resolved to a dealership
// entity, the second did not, so its dealer_id key is absent entirely. Both
// carry dealer_site_id - that one is always there.

That is the whole setup - there is no sandbox and no test key, you call live data from the first request. It costs credits: paging search results runs about 10,000 listings per credit, and a single lookup by id or VIN is the cheapest call there is. From here, browse the full API reference or see every field in the data schema.

Authentication

Send your API key as a bearer token on every /v1_2/listings/* request. The key identifies you, resolves to your shared credit wallet, and carries its own rate limit (100 requests/second).

Authorization: Bearer hd_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Generate and manage your key from the API key page. Keep it secret - you only see it in full once. Auth fails closed: an empty or frozen wallet is refused. The liveness probe GET /health needs no key. One key covers both versions - nothing to re-issue or re-provision to move from /v1 to /v1_2.

Conventions

These rules hold across every endpoint.

Base URL

All routes live under https://luxe.hermesdata.io/v1_2/listings. Requests and responses are JSON.

Sparse records

Only fields with a value appear in a record, so id is the one field you can always count on. Don't assume a key is present.

Correlation

Every response carries an X-Request-Id. Send your own to have it echoed; quote it in support requests.

Units

The catalog spans the US and Canada, and values are passed through in the units the listing was published in - nothing is converted at ingest. Read province_state_code to know which you have:

FieldUS listingCanadian listing
sale_priceUSDCAD
odometermileskilometres
distance_kmAlways kilometres, both countries.

The practical consequences: a cross-border price_min/price_max or odometer_min/odometer_max filter compares raw numbers across two units, and sort=price_asc orders USD and CAD in one list. Filter to one country (or convert on your side) when that matters. Aggregates from the stats endpoints inherit the same mix.

Response envelopes

You'll get one of three shapes, depending on the endpoint:

Lists

{ "data": [ … ], "meta": { … } }

Search, dealer inventory, recents, and batch come wrapped in data + meta.

Point lookups

{ "id": "v_9f3a21", … }

One vehicle by id or VIN comes back on its own - no wrapper. Resolve payloads are bare objects too.

Analytics

{ "regions": [ … ] }

Stats endpoints each keep their own shape (comparables, stats, mds, popular…).

Two null conventions. Cards are sparse: a field with no value is omitted, so an unresolved dealer_id means the key isn't there at all. The claim entries inside a VIN lookup (co_listings / other_claimants) are the opposite - a fixed shape with explicit nulls, so you can index every field unconditionally.

Pagination

For most uses, numbered pages are all you need: send page (1-based) and page_size (1–1000, default 25), and read meta.total for how many rows matched in total. A page_size over 1000 is silently reduced to 1000 and echoed back at that value, so read the echo rather than assuming your number was used.

Search-style calls (the scan price class - see Metering) are billed on rows returned, rounded up to the next 100: 1 row and 100 rows both cost one unit, 250 rows cost three, 1000 rows cost ten. The per-row price is the same at any page_size, so asking for a bigger page saves round trips, not credits.

Only if you need to walk every result: numbered pages stop at max_offset (see /meta) - go past it and you get 400 offset_too_deep. To go deeper, page by cursor instead: every paged response carries meta.next_cursor, and sending it back as ?cursor=… resumes exactly where the last page stopped. There is no depth limit and no extra charge - the ten-thousandth page costs what the first one does. Keep the route, sort and filters identical across the walk (a mismatch is 400 invalid_cursor), don't send page and cursor together, and treat the token as opaque. Cursors are pinned to a catalog snapshot: if the nightly rebuild lands mid-walk you get 400 snapshot_expired naming the new version, rather than silently skipped or repeated rows.

Walk an entire result set
import requests

params = {"make": "Ford", "page_size": 100, "sort": "price_asc"}
headers = {"Authorization": "Bearer hd_live_your_key_here"}

while True:
    resp = requests.get("https://luxe.hermesdata.io/v1_2/listings/search", params=params, headers=headers)
    resp.raise_for_status()          # 400 snapshot_expired -> restart the walk
    body = resp.json()

    for listing in body["data"]:
        print(listing["id"])

    cursor = body["meta"].get("next_cursor")
    if not cursor:                   # null on the last page
        break
    params["cursor"] = cursor        # keep the filters and sort - the token only says where to resume
    params.pop("page", None)         # page and cursor are mutually exclusive

Response headers

HeaderSent onMeaning
X-Request-Idevery responseCorrelation id - echoed if you send one, else generated. Quote it in support requests.
Retry-After429 · 503Seconds to wait before retrying.
X-RateLimit-Limit429Your per-key sustained request budget (requests/second).
X-RateLimit-Remaining429Requests left in the current budget after this response.
X-RateLimit-Reset429Seconds until the budget refills to full.

API reference

Every route, grouped. Each row expands to its full input contract - a typed parameter table - a runnable request in your language, and a real response body. Base URL https://luxe.hermesdata.io.

new in v1.2Has no /v1 equivalent.richer in v1.2Same path as v1, with dealer identity added to the response (and, on search, the dealer scopes plus price_changed_within_days).same as v1Byte-identical to its /v1 twin.

Discovery

Ask the catalog what it holds. Read these instead of hard-coding filter menus, sort options, limits, or prices.

Point lookups

Get one vehicle by id or VIN. These return the object on its own - no wrapper. A VIN lookup also tells you every dealer advertising that car.

Search & browse

Filter, page, and sort lists of listings - now scopable to one website or one whole dealership. These return the { data, meta } wrapper.

Sold & lifecycle

The ~90-day window: what left the live catalog, when, and at what price. See Lifecycle & history above for what sold and unverified actually claim.

Dealer identity

Turn a website into the dealership behind it, and pull inventory by either. See Dealer identity above for the concepts.

Analytics

Market stats across the catalog, unchanged from v1. Each returns its own keyed shape, not the { data, meta } wrapper.

Taxonomy

Fill dropdowns and type-ahead boxes from the faceted fields (listed under Data schema). Unchanged from v1.

Service

Liveness probe. No key, no charge - for uptime checks. Not version-scoped.

Recipes

6 end-to-end jobs. Each one is complete - paste it, swap your key, and it works.

Group a market's listings by rooftop

dealer_site_id is a stable key per website, so bucket on it rather than normalizing domains yourself. Dealerships that run several sites collapse further on dealer_id.

Recipe
import requests
from collections import defaultdict

BASE = "https://luxe.hermesdata.io/v1_2/listings"
H = {"Authorization": "Bearer hd_live_your_key_here"}

by_site, by_dealer = defaultdict(list), defaultdict(list)
params = {"state": "TX", "make": "Ford", "page_size": 100}

while True:
    body = requests.get(f"{BASE}/search", params=params, headers=H).json()
    for row in body["data"]:
        by_site[row["dealer_site_id"]].append(row["id"])
        dealer = row.get("dealer_id")          # ABSENT when unresolved
        if dealer:
            by_dealer[dealer].append(row["id"])
    cursor = body["meta"].get("next_cursor")
    if not cursor:
        break
    params = {**params, "cursor": cursor}

print(len(by_site), "websites,", len(by_dealer), "known dealerships")

Pull every listing a dealership has, across all its sites

Two ways in, same union: filter search by dealer_id when you want to combine it with other filters, or call the by-id inventory route when you just want the lot.

Recipe
# Filterable: dealer_id ANDs with every other search param.
curl "https://luxe.hermesdata.io/v1_2/listings/search?dealer_id=d_10c4f2&year_min=2024&sort=price_desc" \
  -H "Authorization: Bearer hd_live_your_key_here"

# Or the whole lot, unioned across every website the dealership runs.
curl "https://luxe.hermesdata.io/v1_2/listings/dealers/id/d_10c4f2/inventory?page_size=100" \
  -H "Authorization: Bearer hd_live_your_key_here"

# Neither errors on an id we can't place: the filter returns an empty result
# set, the inventory route returns 404 dealer_not_found.

Resolve a whole day's sites in one pass

Batch resolve costs one scan charge per request, whatever the id count - so chunk at the 500 cap rather than resolving row by row.

Recipe
import requests

BASE = "https://luxe.hermesdata.io/v1_2/listings"
H = {"Authorization": "Bearer hd_live_your_key_here"}

# site_ids collected from a day of search / recents rows
site_ids = sorted({row["dealer_site_id"] for row in todays_rows})

resolved = {}
for i in range(0, len(site_ids), 500):            # 500 is the per-call cap
    chunk = site_ids[i:i + 500]
    body = requests.post(f"{BASE}/dealers/resolve/batch",
                         json={"site_ids": chunk}, headers=H).json()
    for payload in body["data"]:                  # same order as the input
        resolved[payload["site_id"]] = payload

known = [p for p in resolved.values() if p["resolution"] != "unknown"]
print(f"{len(known)}/{len(site_ids)} sites tied to a dealership")

Find this week's price cuts, then read the whole move

price_changed_within_days filters live rows by their most recent observed price change - ordinary Scan billing, no extra call. /history then costs one keyed charge to see the full trace.

Recipe
import requests

BASE = "https://luxe.hermesdata.io/v1_2/listings"
H = {"Authorization": "Bearer hd_live_your_key_here"}

cut = requests.get(f"{BASE}/search", headers=H, params={
    "state": "TX", "make": "Ford", "model": "F-150",
    "price_changed_within_days": 7,       # 1-90; 90 is the tracking retention
    "page_size": 100,
}).json()

for row in cut["data"]:
    if row.get("listing_status") != "active":
        continue                          # in the catalog, but not seen lately
    hist = requests.get(f"{BASE}/vehicles/{row['id']}/history", headers=H).json()
    drops = [e for e in hist["events"] if e["field"] == "sale_price"]
    if drops:
        first, last = drops[0], drops[-1]
        print(row["id"], first["old"], "->", last["new"], "over", len(drops), "moves")

Build a comps set from cars that actually sold

sold/search pages the ~90-day window. Every page mixes archived rows (full card shape) with thin ones, so branch on record_type - and filter on status == sold before you assert anything about a sale.

Recipe
import requests

BASE = "https://luxe.hermesdata.io/v1_2/listings"
H = {"Authorization": "Bearer hd_live_your_key_here"}

# Hot-column filters bind on ARCHIVED rows only, so this query deliberately
# excludes thin-only rows - that is the trade for filtering on make/model.
params = {"make": "Ford", "model": "F-150", "state": "TX",
          "sold_within_days": 30, "page_size": 100}

prices, thin = [], 0
while True:
    body = requests.get(f"{BASE}/sold/search", params=params, headers=H).json()
    for row in body["data"]:
        if row["status"] != "sold":       # unverified is NOT a sale
            continue
        if row["record_type"] == "lifecycle":
            thin += 1
            prices.append(row["last_price"])
        else:
            prices.append(row["sale_price"])
    cursor = body["meta"].get("next_cursor")
    if not cursor:
        break
    params = {**params, "cursor": cursor}

prices = sorted(p for p in prices if p)
print(len(prices), "sold comps,", thin, "of them thin,",
      "median", prices[len(prices) // 2])

Read a VIN's claims: who has it, and what they're asking

A VIN lookup returns one representative listing plus every other dealer advertising the same car, so you see every claim on it rather than one arbitrary pick.

Recipe
import requests

VIN = "1FTFW1E84PFA12345"
car = requests.get(f"https://luxe.hermesdata.io/v1_2/listings/vehicles/{VIN}",
                   headers={"Authorization": "Bearer hd_live_your_key_here"}).json()

print("representative:", car["dealer_netloc"], car.get("sale_price"))

# Same dealership, its other websites.
for row in car["co_listings"]:
    print("  also at", row["dealer_netloc"], row["sale_price"])

# Other dealerships claiming this VIN. dealer_id is explicitly null here when
# the site is unresolved - claim entries are fixed-shape, unlike sparse cards.
for row in car["other_claimants"]:
    who = row["dealer_id"] or row["dealer_site_id"]
    print("  claimed by", who, "at", row["sale_price"])

Site ids in these examples (4c1f9a2e7b83d05614af2c9e3d70b8a1) are illustrative - real ones come off your own rows or from /dealers/resolve/….

Who is selling the car

Every listing was scraped from a website. That website is run by a dealership, and one dealership often runs several websites. So "who is selling this car" has two answers, and each listing carries both where we know them: the website it was published on (always), and the dealership behind that website (when we can match the two).

dealer_site_id

identifies a website

Always present

Identifies the dealer website a listing was published on. Derived from the listing's own domain, so it is there even for sites we hold no dealership record for. Treat it as an opaque, stable string.

dealer_id

identifies a dealership

Present when resolved

Identifies the dealership that runs this exact website. A direct match, never a neighbour's id - so this is what you attribute a car to. Present only when a dealership record claims the site - ABSENT (not null) otherwise. Joins one-to-one to the Dealers and Dealer Groups products.

entity_dealer_id

identifies a business

Present when resolved

Identifies the business a storefront belongs to. Dealerships at one physical address are one business even though each runs its own site, so this is MANY-TO-ONE: use it to roll a campus up, and never as a per-dealership key or your totals count a campus once per store on it.

resolution

identifies how we linked them

Resolve responses only

How the BUSINESS was reached; dealer_id is a direct match either way. exact = the business's own site · redirect = joined through a redirect · cluster = this storefront was folded into a co-located sibling · unknown = no business (or identity not loaded).

Worked example: four websites, three ways to group them

Example Ford runs two websites - its main one, plus an old domain that now just redirects. Example Kia is a separate dealership sharing the same lot, with its own website. And a fourth website belongs to a dealer we have no record for. Four websites, four site ids:

Websitedealer_site_iddealer_identity_dealer_idresolution
exampleford.com4c1f9a2e7b83d05614af2c9e3d70b8a1d_10c4f2d_10c4f2exact
exampleford.cab73e0d41c85a9f2607d1e4b39c8f5a20absentd_10c4f2redirect
examplekia.com9a4d17e6c0b23f8514ed70a9b62c4f38d_58e7b3d_10c4f2cluster
northsideauto.examplee05b6c37a91d4f28b3c7069e5a1d82f4absentabsentunknown
  • Group by dealer_site_id and you get four buckets - one per website.
  • Group by dealer_id and you get one bucket per dealership - two here. The redirect domain has no dealership of its own, so it drops out. Group by entity_dealer_id and the first three collapse into one bucket, because Example Ford and Example Kia sit on the same lot and count as one business. The fourth website has neither key, so group it by its site id.
  • Resolving either of the first two names the other in its sibling_site_ids (capped at 50).
  • Inventory by dealer id serves one storefront - /dealers/id/d_10c4f2/inventory - while inventory by entity id serves the whole campus as one paged result set: /dealers/entity/d_10c4f2/inventory.

Code for a missing dealer_id

dealer_site_id is on every row. dealer_id is not: when a website doesn't resolve to a dealership we know, the key is absent from the object entirely - not null, not an empty string. The same is briefly true of every row in the window after a deploy, while the dealer-resolution map loads. Read it defensively (row.get("dealer_id"), row.dealer_id ?? null) and never treat its absence as an error. You can see it in the Quickstart response.

Coverage is not total. In our most recent sample (2026-08-16), every Ontario website and 99% of Quebec websites we checked resolved to a dealership. A website we hold no dealer record for still gives you a usable dealer_site_id - you just can't name the business behind it.

Stability: what's safe to store

site ids are forever

A dealer_site_id is a stable, opaque 32-character string for one website. Persist it indefinitely - the same website always yields the same id. Read it off any row, or ask /dealers/resolve/…; don't try to derive or parse one.

A rename is a new site

Site identity follows the domain. If a dealer moves to a new domain, that is a new dealer_site_id. Continuity across a rename is dealer_id's job, which is why it is the right key for long-term storage.

One domain, one site

Identity is per website, not per building. A single domain fronting several physical rooftops (per-path microsites on a shared host) is one site and one id - we cannot split it at this granularity. Use the listing's own city / postal code when you need to separate those rooftops.

While identity is warming up

The dealer-resolution map loads asynchronously after a deploy. v1.2 keeps serving throughout - nothing 503s and no shape changes - but the dealership half of identity is briefly missing. Build for this column and you are also built for websites we simply can't place:

FeatureNormallyWhile warming up
dealer_site_id on rowsPresentPresent - computed from the listing's own domain
dealer_id on rowsPresent when resolvedAbsent
entity_dealer_id on rowsPresent when resolvedAbsent
site_id search filterWorksWorks - it never needed identity
dealer_id search filterScopes to that dealership's own siteEmpty result set (200, not an error)
entity_dealer_id search filterExpands to every site of the businessEmpty result set (200, not an error)
resolve endpointsFull payloadsresolution: "unknown", netloc still echoed when we know the site
inventory by dealer_idThat dealership's own carsFalls back to the single-site v1 behavior
inventory by entity_dealer_idUnion of every site of the business404 - there is no v1 shape to fall back to
VIN claim groupsSites of one business merge into one claimEvery site is its own claim - nothing merges

Joining listings to firmographics

dealer_id is the dealership that runs the website a listing came from - the same identifier the Dealers and Dealer Groups products key on, one-to-one. Where a rooftop exists in that catalogue, its listings and its firmographic record (legal name, phone lines, franchise mix, headcounts, martech) line up on one id, so you can go from "who is advertising this car" to "who do I call" without matching on names or domains. A listing itself never carries firmographics, and a website we have no dealer record for has no dealer_id to join on.

entity_dealer_id answers the other question - which business a storefront belongs to. Dealerships at one address are one business even though each runs its own site, so it is many-to-one: roll a campus up with it, and never use it as a per-dealership key or a per-rooftop total counts that campus once per store on it.

Sold cars & price history

When a car comes off the market we keep its record for about 90 days, along with every price and odometer change we saw while it was listed. Two things follow. A VIN or id that no longer matches a car for sale still returns something instead of a 404, and you can ask what a car did - what it listed at, what it dropped to, whether it sold - not only what it costs today.

Three states, three different claims

active

listing_status, live rows

We saw it today

The crawl saw this row on the snapshot's as-of day or the day before it. The one-day tolerance is deliberate: single-day crawl flicker runs around 40%, so demanding same-day evidence would mark healthy listings dead every other day.

unverified

listing_status · status

We stopped seeing it - and that is all

Two or more days without the crawl seeing the row. This is NOT evidence of a sale. Much of the pool is crawler pathology on sites that are perfectly live. Never present an unverified record as a sold car, and never count one into sold volume or days-to-sale.

sold

status, window rows

It disappeared the way sold cars disappear

Derived from healthy crawl evidence - the producer needs several missed passes across several calendar days with corroborating evidence before it concludes a sale. It is a strong heuristic, not a title transfer, and it never carries a buyer or a sale price beyond the last asking price.

Never count unverified as sold

unverified means our crawl stopped seeing the listing. That happens when a car sells, and it also happens when a feed breaks or a site changes shape - a large part of that pool sits on dealer websites that are still perfectly live. Treating it as a sale inflates sold volume and shortens every days-to-sale figure you compute. Filter on status == "sold" before you assert anything about a car changing hands.

One key, three pools

A lookup by id or VIN checks three places in order - the live catalog, then the 90-day archive, then a last-resort index that holds only status and dates - and answers from the first one that has the record. Same route, same keyed charge - different body shapes, told apart by record_type:

Live catalog

no record_type key

The ordinary sparse card every listings route returns, now carrying listing_status. Live always wins a key lookup - including for a relisted car the archive still holds an older sale for.

Archive

record_type: "sold" | "unverified"

Records whose full row we captured on the way out: the same card field names, plus status and sold_date. On /full the captured payload hydrates too, byte-identical to that record's last live VDP. Coverage grows day over day.

Thin lifecycle

record_type: "lifecycle"

Everything we did not capture in full - lifecycle facts only, in a FIXED shape with explicit nulls (the opposite of the sparse card). One row per observation window, so a relisted car has one row per closed cycle plus its current one.

The same key, before and after
// The SAME key that answered live above, after the car sold. Card route:
{
  "record_type": "sold",
  "status": "sold",
  "sold_date": "2026-08-29",
  "id": "v_9f3a21",
  "vin": "1FTFW1E84PFA12345",
  "make_name": "Ford",
  "model_name": "F-150",
  "year": 2024,
  "sale_price": 58995,
  "dealer_netloc": "exampleford.com",
  "dealer_site_id": "4c1f9a2e7b83d05614af2c9e3d70b8a1",
  "dealer_id": "d_10c4f2",
  "entity_dealer_id": "d_10c4f2",
  "first_seen": "2026-07-19",
  "last_seen": "2026-08-27"
}

// And an id we never captured a full row for - the thin body, all we have:
{
  "record_type": "lifecycle",
  "id": "v_51ba07",
  "vin": null,
  "status": "unverified",
  "sold_date": null,
  "first_seen": "2026-06-30",
  "last_seen": "2026-08-02",
  "days_listed": 33,
  "last_price": 24990,
  "source": "northsideauto.example",
  "listing_cycle": 1,
  "is_current": true
}
// Live always wins: a relisted id answers with its live card even while the
// archive still holds the old sale. So a 404 vehicle_not_found now means "never
// seen inside the window", not "not for sale today".
//
// Archived sold cards ARE dealer-enriched on the v1.2 keyed routes. Thin bodies
// are not - there is no netloc-bearing card to enrich, only a source string.
// Neither shape ever carries claim groups.

Rules worth reading once

A window closes only on evidence, never on a gap

If we lose sight of a car for a week and then see it again, that is ONE window - the gap does not split it. So two windows on a vehicle always mean we positively concluded a sale between them, and a relist you can count on.

Events are observation-dated, not interpolated

An event is dated the day we SAW the change, so changes that happen across missed pulls collapse into one event on the day we noticed. Price and odometer only - every other field is latest-copy-wins with no history. An empty events array means no change observed while tracked, never that the price held.

Two row shapes share one page

sold/search returns archived and thin rows interleaved, and the mix shifts as archive coverage grows. Branch on record_type on every row. Hot-column filters (make, model, year, price, geo...) bind on archived rows only, so using one silently excludes every thin row.

The three routes this section is about

  • /vehicles/{key}/history - dated changes and listing windows for one vehicle, at the keyed price.
  • /sold/search - page the window by date, dealer and the hot columns. One row per sold window, so a car sold twice inside 90 days is two rows.
  • /search?price_changed_within_days= - live cars whose asking price moved recently, at the ordinary Scan price.

Full contracts are in the API reference, and the body shapes are laid out under Data schema.

What's new in v1.2

Only relevant if you already have code on v1. If this is your first read, skip it.

Change the prefix, keep everything else. Every /v1/listings/* route is mirrored at /v1_2/listings/* with the same key, the same rate limits, the same paging, the same errors and the same prices. A client that changes nothing but the prefix gets the same data plus dealer identity and a serve-time status on every row - and two new routes over the ~90-day window behind it.

dealer_site_id on every row

A stable id for the website a listing came from, so you can group listings by rooftop without string-matching domains.

dealer_id where we know the dealership

The dealership that runs the site a listing came from - the same id the Dealers and Dealer Groups products use, so listings join to firmographics one-to-one.

entity_dealer_id for co-located stores

Stores sharing one address are one business. This is the roll-up key for a whole campus - and it is many-to-one, so never use it as a per-rooftop id.

Dealer-aware VIN lookups

One car listed on several sites returns one representative plus every other claim, instead of an arbitrary pick.

Resolve endpoints

Turn a site id into the dealership behind it, its name, and its sibling sites - one at a time or 500 per call.

listing_status on every row

active or unverified, derived at serve time - so "in the catalog" and "verifiably for sale today" stop being the same claim.

A ~90-day window behind every key

A key that no longer matches a live car answers from the sold archive or the lifecycle pool instead of 404ing. A 404 now means never seen in the window.

Dated price and odometer history

/history returns every observed change and the vehicle's distinct listing windows, at the cheapest cost class. /full carries the same events inline.

Search the sold pool

/sold/search filters what left the catalog by date, dealer and the hot columns - the comps set for what actually moved, not what is still sitting.

price_changed_within_days

Find live cars whose asking price moved in the last N days (1-90) - a plain search filter at the ordinary Scan price.

v1 is not deprecated and has no scheduled removal date - pin whichever version you like. . One change is not a v1.2 feature and applies to both: photo_urls is returned in the dealer's own order, with photo_urls[0] as the lead photo.

Data schema

Every listing route returns a card - the fields below, grouped by topic. Only fields with a value appear, and id is always there.

Dealer identity · added by v1.2

On top of every card field below, v1.2 rows carry:

  • dealer_site_id · string · the website, 32 hex chars · always present
  • dealer_id · string · the dealership entity · absent when unresolved

Identity

  • id · string · always present
  • vin · string
  • make_name · string
  • model_name · string
  • trim_name · string
  • year · integer

Specs & pricing

  • sale_price · integer · USD in the US, CAD in Canada
  • odometer · integer · miles in the US, km in Canada
  • body_style · string
  • fuel_name · string
  • drive_train · string
  • transmission_name · string
  • exterior_color · string
  • interior_color · string
  • certified · boolean
  • new_vehicle · boolean

Location & lifecycle

  • province_state_code · string
  • city · string
  • postal_code · string
  • latitude · number
  • longitude · number
  • dealer_netloc · string
  • distance_km · number · km, geo results only
  • first_seen · date
  • last_seen · date
  • first_photo_url · string · lead photo; photo_urls[0] on the full record
  • listing_status · enum · active | unverified - derived at serve time, always present

Listing detail

Passthrough columns, straight off the source feed. Display-only - not queryable.

  • title · string · source listing title, as published
  • dealer · string · dealer name as published (dealer_netloc is the queryable form)
  • street_address · string · selling rooftop's street address
  • province_state · string · full province/state name
  • country · string
  • stock_number · string
  • engine_description · string
  • title_status · string
  • window_sticker · string
  • source · string · feed the listing was crawled from
  • crawled_timestamp · number · unix epoch seconds at crawl time

Pickups & fuel (raw)

Passthrough columns, straight off the source feed. Display-only - not queryable.

  • cab_style · string · pickups only, where the feed supplies it
  • bed_length · string · pickups only, where the feed supplies it
  • fuel_capacity · string · unparsed: "16 gallons", "52 liters", bare "13.2"
  • fuel_efficiency_city · string · raw; mpg in the US, L/100km in Canada
  • fuel_efficiency_hwy · string · raw; unit not guaranteed
  • fuel_efficiency_cmb · string · raw; ~41% of US rows, under 3% of Canadian

Records inside the 90-day window

What /sold/search pages and what a keyed lookup falls through to. Two shapes on one page - read record_type first.

Archived record

record_type: "sold" | "unverified"
  • record_type · enum · equals status; names the SHAPE, not a separate claim
  • status · enum · sold | unverified
  • sold_date · date | null · always null when status is unverified
  • …every Card field · as captured at the transition, same names, still sparse

Thin lifecycle record

record_type: "lifecycle"
  • record_type · const · "lifecycle"
  • id · string
  • vin · string | null
  • status · enum · sold | unverified
  • sold_date · date | null
  • first_seen · date | null
  • last_seen · date | null
  • days_listed · integer | null
  • last_price · integer | null · last observed asking price, listing's local currency
  • source · string | null · the feed it was crawled from
  • listing_cycle · integer | null · which observation window this row is
  • is_current · boolean · false = a closed, always-sold past window

History body

Returned by /vehicles/{key}/history. Fixed shape with explicit nulls - every field is always there.

History body

GET /vehicles/{key}/history

  • id · string
  • vin · string | null
  • record_status · enum · active | unverified | sold
  • windows[] · array · one entry per observation window, cycle-ascending per source
  • events[] · array · every observed change, oldest first; empty is a real answer

windows[] entry

one listing window at one source

  • listing_cycle · integer | null
  • first_seen · date | null
  • last_seen · date | null
  • sold_date · date | null · present on every closed window, never on the open one
  • last_price · integer | null · last asking price inside this window
  • source · string | null · the VDP site the window was observed at
  • current · boolean · at most one current window per source

events[] entry

one observed change

  • date · date · the day we OBSERVED it, never interpolated
  • field · enum · sale_price | odometer - nothing else is tracked
  • old · integer | null · null when the value appeared
  • new · integer | null · null when the value disappeared

Coordinates are missing on ~15% of listings

latitude/longitude come from the selling rooftop, and about 15% of the catalog has none. A geo search excludes every one of those listings, whatever else they matched, and meta.total reflects the reduced set with no separate count of what was dropped. The gap is uneven by market - under 5% in QC, over 30% in YT, NB, MB, AK and MT. To tell "outside the radius" from "location unknown", run the same search without the geo triple and compare.

Fuel-economy values have no guaranteed unit

fuel_efficiency_* is a raw source string: US listings report mpg (higher is better), Canadian listings report L/100km (lower is better), and only about half the Canadian values carry the "L/100km" suffix. A bare number is genuinely ambiguous - 8 to 25 reads plausibly on both scales - and "0" means unknown, not zero consumption. Resolve the unit from the value's own suffix, then from province_state_code, before sorting or aggregating across the border.

Claim entries on a VIN lookup

co_listings and other_claimants hold this fixed shape - every field always present, unknown values explicitly null:

id · stringdealer_site_id · string | nulldealer_netloc · string | nulldealer_id · string | nullsale_price · integer | nullfirst_seen · date | nulllast_seen · date | null

…/full adds more

The /full endpoint returns the card plus these extra fields:

events[]notesoptions[]features[]packages[]phones[]emails[]photo_urls[]history_urls[]

Faceted fields

The field parameter for taxonomy/terms and autocomplete accepts:

make_namemodel_nametrim_namebody_stylefuel_namedrive_traintransmission_nameexterior_colorinterior_colorprovince_state_codecity

Sort values

The sort parameter on list routes accepts:

relevanceprice_ascprice_descodometer_ascodometer_descyear_descdistance

Errors

Every error shares one envelope. Branch on code - it's stable; the message is for humans and may change. Some 400s add a details[] array pinpointing the offending parameter. On 429 and 503, wait for the Retry-After header before retrying. v1.2 introduces no new error codes.

Error body · shape
{
  "error": true,
  "code": "invalid_range",
  "message": "year_min must be ≤ year_max.",
  "details": [
    { "field": "year_min", "issue": "greater than year_max" }
  ]
}
StatuscodeWhen
401invalid_api_keyMissing, malformed, invalid, or revoked bearer key
402insufficient_creditsWallet balance exhausted - top up to continue
402spend_frozenWallet is on a billing hold - resolve it on your account
429rate_limitedRate limit exceeded (per-key rps or spend velocity) - back off per Retry-After
400invalid_queryMissing or mistyped query parameter. On sold/search: an unrecognised status, a rolling window sent alongside sold_from/sold_to, or a malformed date. On search: price_changed_within_days outside 1-90
400invalid_bodyMalformed request body
400invalid_geolat/lon/radius_km not supplied as a valid triple
400invalid_sortsort value not in the allowed set
400invalid_fieldfield not a faceted field (taxonomy/autocomplete)
400invalid_rangeA *_min greater than its *_max
400offset_too_deeppage × page_size beyond max_offset - traverse with cursor instead
400invalid_cursorCursor malformed, sent alongside page, or replayed against a different route or sort
400snapshot_expiredCursor was minted against an older catalog snapshot - restart the walk
400id_or_vin_requiredcomparables called without id or vin
400batch_emptyBatch called with zero ids
400batch_too_largeMore than 100 ids on a vehicle batch, or more than 500 site ids on a resolve batch
404vehicle_not_foundKey unknown in the live catalog AND in the ~90-day window - not merely no longer for sale
404dealer_not_foundUnknown dealer id / netloc / site id on an inventory route. Resolve never 404s - it answers resolution: "unknown"
503catalog_unavailableSnapshot not resident yet - retry per Retry-After
503auth_unavailableWe could not reach the key resolver. Fails closed - never grants access. Retry per Retry-After

One exception: a request that times out returns a plain 408 with no JSON body - treat any 408 as a timeout.

  • 402 spend_frozen carries a reason of payment_dispute or past_due. Buying credits does not clear it - resolve the billing hold on your account.
  • An unknown site_id or dealer_id is not an error on search (empty result set) or on resolve (resolution: "unknown"). It is a 404 dealer_not_found on the inventory routes, which need a real target.

Metering & credits

No subscription - each request costs a little from one shared credit wallet, based on the kind of request. Because a single call costs far less than a whole credit, prices here are quoted in millicredits (mc): 1,000 mc = 1 credit. So a 10 mc search page is a hundredth of a credit. Your wallet and your invoices only ever show whole credits. Run out and you get 402 insufficient_credits. The current price for each kind of request is always live at GET /v1_2/listings/meta - read it from there rather than hard-coding.

keyed1 mc

A single lookup by id or VIN - the smallest charge. Batch counts one per id, and a vehicle's price history is the same class.

aggregate2 mc

Catalog info and stats - meta, stats, popular, taxonomy, autocomplete.

vdp5 mc

A full record with every extra field.

scan10 mc

A filtered search or list - one charge per 100 rows returned, so a page of 1 and a page of 100 cost the same.

Note: a batch lookup is priced per id - each id is one keyed charge - and any 4xx/5xx error is refunded, so failed calls never cost credits. Two safety limits apply: 100 requests/second per key and a spend ceiling of 120 credits/minute, each returning 429 with a Retry-After.

v1.2 costs the same as v1. Every mirrored route bills exactly what its /v1 twin bills - dealer identity on a row is free. The three new dealer routes bill at the scan class, the same as dealer inventory, and resolve/batch is one scan charge per request however many of its 500 site ids you use.

Pricing

One shared wallet, no subscription - you're billed per API call by request type, and errors are never charged. Roughly 10,000 listings per credit when you page search; full records, lookups, and stats meter differently:

One shared wallet — billed per API call, not per row

What you're doingPer credit
Search & browsePaging search results, charged per 100 listings returned.~10,000search results
Full recordsEvery field: options, features, photos, seller contacts.~200full records
Point lookupsOne vehicle by id or VIN — batch counts one per id, and price history is the same class.~1,000lookups
Market statsRegional & YMM stats, popular, taxonomy, autocomplete.~500stat calls

Approximate reach per workflow — the exact cost is metered per call at pull time, and errors are never billed.

Need the whole file instead?

This API · query

Self-serve, per call

  • Search, filter, and look up listings in real time
  • JSON over HTTPS, billed per request

Bulk file delivery

Enterprise plan

  • Full-feed exports and recurring file deliveries
  • Scope, schedule, and pricing set per arrangement
Talk to us about file delivery

There is no bulk file export or download endpoint on the query API.

Ready for API access?

Generate a key and start querying in minutes.

Get API access