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.
https://luxe.hermesdata.io/v1_2/listingsOverview
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.
Create one API key in your portal. It's your bearer token for every request.
Search and filter, look up by id or VIN, or pull dealer inventory and market stats.
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=3A 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.
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"{
"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_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxGenerate 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.
All routes live under https://luxe.hermesdata.io/v1_2/listings. Requests and responses are JSON.
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.
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:
| Field | US listing | Canadian listing |
|---|---|---|
| sale_price | USD | CAD |
| odometer | miles | kilometres |
| distance_km | Always 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.
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 exclusiveResponse headers
| Header | Sent on | Meaning |
|---|---|---|
| X-Request-Id | every response | Correlation id - echoed if you send one, else generated. Quote it in support requests. |
| Retry-After | 429 · 503 | Seconds to wait before retrying. |
| X-RateLimit-Limit | 429 | Your per-key sustained request budget (requests/second). |
| X-RateLimit-Remaining | 429 | Requests left in the current budget after this response. |
| X-RateLimit-Reset | 429 | Seconds 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.
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.
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.
# 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.
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.
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.
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.
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_ididentifies a website
Always presentIdentifies 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_ididentifies a dealership
Present when resolvedIdentifies 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_ididentifies a business
Present when resolvedIdentifies 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.
resolutionidentifies how we linked them
Resolve responses onlyHow 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:
| Website | dealer_site_id | dealer_id | entity_dealer_id | resolution |
|---|---|---|---|---|
| exampleford.com | 4c1f9a2e7b83d05614af2c9e3d70b8a1 | d_10c4f2 | d_10c4f2 | exact |
| exampleford.ca | b73e0d41c85a9f2607d1e4b39c8f5a20 | absent | d_10c4f2 | redirect |
| examplekia.com | 9a4d17e6c0b23f8514ed70a9b62c4f38 | d_58e7b3 | d_10c4f2 | cluster |
| northsideauto.example | e05b6c37a91d4f28b3c7069e5a1d82f4 | absent | absent | unknown |
- Group by
dealer_site_idand you get four buckets - one per website. - Group by
dealer_idand you get one bucket per dealership - two here. The redirect domain has no dealership of its own, so it drops out. Group byentity_dealer_idand 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
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.
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.
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:
| Feature | Normally | While warming up |
|---|---|---|
| dealer_site_id on rows | Present | Present - computed from the listing's own domain |
| dealer_id on rows | Present when resolved | Absent |
| entity_dealer_id on rows | Present when resolved | Absent |
| site_id search filter | Works | Works - it never needed identity |
| dealer_id search filter | Scopes to that dealership's own site | Empty result set (200, not an error) |
| entity_dealer_id search filter | Expands to every site of the business | Empty result set (200, not an error) |
| resolve endpoints | Full payloads | resolution: "unknown", netloc still echoed when we know the site |
| inventory by dealer_id | That dealership's own cars | Falls back to the single-site v1 behavior |
| inventory by entity_dealer_id | Union of every site of the business | 404 - there is no v1 shape to fall back to |
| VIN claim groups | Sites of one business merge into one claim | Every 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
activelisting_status, live rows
We saw it todayThe 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.
unverifiedlisting_status · status
We stopped seeing it - and that is allTwo 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.
soldstatus, window rows
It disappeared the way sold cars disappearDerived 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 keyThe 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 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 presentdealer_id· string · the dealership entity · absent when unresolved
Identity
id· string · always presentvin· stringmake_name· stringmodel_name· stringtrim_name· stringyear· integer
Specs & pricing
sale_price· integer · USD in the US, CAD in Canadaodometer· integer · miles in the US, km in Canadabody_style· stringfuel_name· stringdrive_train· stringtransmission_name· stringexterior_color· stringinterior_color· stringcertified· booleannew_vehicle· boolean
Location & lifecycle
province_state_code· stringcity· stringpostal_code· stringlatitude· numberlongitude· numberdealer_netloc· stringdistance_km· number · km, geo results onlyfirst_seen· datelast_seen· datefirst_photo_url· string · lead photo; photo_urls[0] on the full recordlisting_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 publisheddealer· string · dealer name as published (dealer_netloc is the queryable form)street_address· string · selling rooftop's street addressprovince_state· string · full province/state namecountry· stringstock_number· stringengine_description· stringtitle_status· stringwindow_sticker· stringsource· string · feed the listing was crawled fromcrawled_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 itbed_length· string · pickups only, where the feed supplies itfuel_capacity· string · unparsed: "16 gallons", "52 liters", bare "13.2"fuel_efficiency_city· string · raw; mpg in the US, L/100km in Canadafuel_efficiency_hwy· string · raw; unit not guaranteedfuel_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 claimstatus· enum · sold | unverifiedsold_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· stringvin· string | nullstatus· enum · sold | unverifiedsold_date· date | nullfirst_seen· date | nulllast_seen· date | nulldays_listed· integer | nulllast_price· integer | null · last observed asking price, listing's local currencysource· string | null · the feed it was crawled fromlisting_cycle· integer | null · which observation window this row isis_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· stringvin· string | nullrecord_status· enum · active | unverified | soldwindows[]· array · one entry per observation window, cycle-ascending per sourceevents[]· array · every observed change, oldest first; empty is a real answer
windows[] entry
one listing window at one source
listing_cycle· integer | nullfirst_seen· date | nulllast_seen· date | nullsold_date· date | null · present on every closed window, never on the open onelast_price· integer | null · last asking price inside this windowsource· string | null · the VDP site the window was observed atcurrent· boolean · at most one current window per source
events[] entry
one observed change
date· date · the day we OBSERVED it, never interpolatedfield· enum · sale_price | odometer - nothing else is trackedold· integer | null · null when the value appearednew· 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_codecitySort values
The sort parameter on list routes accepts:
relevanceprice_ascprice_descodometer_ascodometer_descyear_descdistanceErrors
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": true,
"code": "invalid_range",
"message": "year_min must be ≤ year_max.",
"details": [
{ "field": "year_min", "issue": "greater than year_max" }
]
}| Status | code | When |
|---|---|---|
| 401 | invalid_api_key | Missing, malformed, invalid, or revoked bearer key |
| 402 | insufficient_credits | Wallet balance exhausted - top up to continue |
| 402 | spend_frozen | Wallet is on a billing hold - resolve it on your account |
| 429 | rate_limited | Rate limit exceeded (per-key rps or spend velocity) - back off per Retry-After |
| 400 | invalid_query | Missing 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 |
| 400 | invalid_body | Malformed request body |
| 400 | invalid_geo | lat/lon/radius_km not supplied as a valid triple |
| 400 | invalid_sort | sort value not in the allowed set |
| 400 | invalid_field | field not a faceted field (taxonomy/autocomplete) |
| 400 | invalid_range | A *_min greater than its *_max |
| 400 | offset_too_deep | page × page_size beyond max_offset - traverse with cursor instead |
| 400 | invalid_cursor | Cursor malformed, sent alongside page, or replayed against a different route or sort |
| 400 | snapshot_expired | Cursor was minted against an older catalog snapshot - restart the walk |
| 400 | id_or_vin_required | comparables called without id or vin |
| 400 | batch_empty | Batch called with zero ids |
| 400 | batch_too_large | More than 100 ids on a vehicle batch, or more than 500 site ids on a resolve batch |
| 404 | vehicle_not_found | Key unknown in the live catalog AND in the ~90-day window - not merely no longer for sale |
| 404 | dealer_not_found | Unknown dealer id / netloc / site id on an inventory route. Resolve never 404s - it answers resolution: "unknown" |
| 503 | catalog_unavailable | Snapshot not resident yet - retry per Retry-After |
| 503 | auth_unavailable | We 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_frozencarries areasonofpayment_disputeorpast_due. Buying credits does not clear it - resolve the billing hold on your account.- An unknown
site_idordealer_idis not an error on search (empty result set) or on resolve (resolution: "unknown"). It is a404 dealer_not_foundon 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.
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.
Catalog info and stats - meta, stats, popular, taxonomy, autocomplete.
A full record with every extra field.
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 doing | Per 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
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.