Read a car's listing history before you finance it or take it in trade
History by VIN returns the listings MarketCheck has recorded for a car, and lenders, insurers, marketplaces and appraisers each read something different in them.
MarketCheck5 min read
For developers at lenders, insurers, marketplaces and appraisal tools
History by VIN, GET /v2/history/car/{vin}, returns the listings MarketCheck has recorded for one VIN. Each row is one listing: its asking price, the mileage it showed, where and by whom it was listed, and when it was first and last seen. The docs say MarketCheck has tracked listings since 2015, so a used car's history can reach back through several owners and stores.
The API returns rows and leaves the reading to you. That suits very different jobs: a price-history panel for shoppers, a check of the paperwork for lenders and insurers, a screen for mileage that goes backwards, and a screen for cars that keep changing dealers.
In short. One call per VIN returns up to 50 rows a page, newest first unless you pass
sort_order=asc.fieldspicks the columns, including optional ones such asdealer_id,dom_activeandis_searchable. An unknown or invalid VIN returns an empty array with a 200, so validate VINs before you read an empty history as a clean one.
What a history looks like
The documented "vehicle research" sample asks for VIN 5GTEN63L888227488 with fields covering price, mileage, colors, days on market, dates, seller and location. It returns 50 rows, a full page, so a second page may hold more. Five rows, oldest last, trimmed to the fields discussed and with seller names removed:
[
{ "id": "5GTEN63L888227488-92ef177e-3007", "price": 11822, "miles": 151925,
"first_seen_at_date": "2025-07-09T00:55:19.000Z", "last_seen_at_date": "2025-07-22T00:47:31.000Z",
"city": "Nampa", "state": "ID", "inventory_type": "used" },
{ "id": "5GTEN63L888227488-6350a6b4-78b1", "price": 13240, "miles": 151925,
"first_seen_at_date": "2025-04-04T08:05:31.000Z", "last_seen_at_date": "2025-06-21T08:05:42.000Z",
"city": "Boise", "state": "ID", "inventory_type": "used" },
{ "id": "5GTEN63L888227488-6d03e42e-a51e", "miles": 61918,
"first_seen_at_date": "2020-10-02T14:24:45.000Z", "last_seen_at_date": "2025-04-29T01:59:50.000Z",
"city": "Calgary", "state": "AB", "inventory_type": "used" },
{ "id": "5GTEN63L888227488-a630c2a9-811e", "miles": 99649,
"first_seen_at_date": "2019-12-07T07:23:02.000Z", "last_seen_at_date": "2020-01-07T01:06:54.000Z",
"city": "Calgary", "state": "AB", "inventory_type": "used" },
{ "id": "5GTEN63L888227488-ef33e99c-8e94-4980-93c6-6c9a30028f2c", "price": 22988, "miles": 68509,
"first_seen_at_date": "2018-02-03T16:52:03.000Z", "last_seen_at_date": "2018-02-04T22:42:28.000Z",
"city": "Davenport", "state": "IA", "inventory_type": "used" }
]Read in date order, the sample describes a car with a complicated past. It was listed in Iowa and Idaho in 2018 and in Calgary from 2018 to 2025, then by stores in Boise and Nampa from Mar 06, 2025. Its mileage readings do not line up: 68,565 on May 13, 2018, 99,649 on Dec 07, 2019, 61,918 on a Calgary listing that stayed up from Oct 02, 2020 to Apr 29, 2025, then 151,914 on Mar 06, 2025. And many of the 50 rows are copies: in 2025 the same price and mileage appear on several store websites at once.
Two rules from the listing lifecycle guide make rows easier to read. MarketCheck starts a new listing whenever the price or mileage changes, so the rows for one store trace its asking-price path, with a new row at each change. And last_seen_at_date is the last day the listing was seen live at that price.
A price-history panel for shoppers
- Shopper opens a car
- History by VIN
- Collapse copies into events
- Price and mileage timeline
A marketplace can show how long a car has been for sale and what it was listed for before, store by store. Request sort_order=asc so the rows arrive oldest first. Collapse rows that share a price and mileage over overlapping dates into one event, or the panel repeats itself once per syndicated copy. Rows with no price field are common in the sample, so skip them for the price line and keep them for the dates.
Lenders and insurers: the paperwork against the advertisements
- Application or claim
- History by VIN
- Compare stated miles and price
- Underwriter or adjuster decides
A lender at origination can set the odometer statement beside the car's most recent listed mileage, and the loan amount beside its most recent asking prices. An insurer handling a total-loss claim can see whether the car was advertised for sale shortly before the loss, and at what asking price and mileage. Both checks turn a mismatch into a question for a person, with the listing rows attached as evidence. Each row's id also works with Car Listing Details, which answers for expired listings, so the file can include the photos and features the car was advertised with.
Unusual use: odometer drops and dealer hops
- VINs to screen
- Every page of history
- Stints by attributed dealer
- Flags with the evidence
- Reviewer decides
Two checks run on the same structure. First, drop the syndicated copies: ask for is_searchable, which marks the listing MarketCheck attributes to the dealer that has the car, and keep those rows when the field is present. Then group consecutive rows by dealer_id into stints. A new stint with a different dealer is a hop, and a stint whose lowest reading sits well below an earlier peak is an odometer flag.
import os
from datetime import datetime
import requests
BASE = "https://api.marketcheck.com/v2"
KEY = os.environ["MARKETCHECK_API_KEY"]
FIELDS = ("id,price,miles,inventory_type,first_seen_at_date,last_seen_at_date,"
"source,dealer_id,city,state,is_searchable")
def history(vin):
"""Every page of a VIN's listings. An unknown VIN comes back as an empty list."""
rows, page = [], 1
while True:
r = requests.get(f"{BASE}/history/car/{vin}", timeout=60, params={
"api_key": KEY, "fields": FIELDS, "sort_order": "asc", "page": page})
if r.status_code == 422: # past the last page
break
r.raise_for_status()
batch = r.json()
rows += batch
if len(batch) < 50: # a short page is the last one
break
page += 1
return rows
def stints(rows):
"""Consecutive listings by one attributed dealer, oldest first."""
# Without the flag, one stay syndicated to a group's sites splits into many stints
kept = [r for r in rows if r.get("is_searchable")] or rows
out = []
for r in sorted(kept, key=lambda r: r["first_seen_at_date"]):
seller = r.get("dealer_id") or r.get("source")
if not out or out[-1]["seller"] != seller:
out.append({"seller": seller, "state": r.get("state"), "start": r["first_seen_at_date"],
"end": r["last_seen_at_date"], "asks": [], "miles": []})
s = out[-1]
s["end"] = max(s["end"], r["last_seen_at_date"])
if r.get("price"):
s["asks"].append(r["price"])
if r.get("miles"):
s["miles"].append(r["miles"])
for s in out:
start, end = (datetime.fromisoformat(s[k].replace("Z", "+00:00")) for k in ("start", "end"))
s["days_listed"] = (end - start).days
return out
def odometer_flags(stints, tolerance):
"""Stints whose lowest reading is more than `tolerance` miles below an earlier peak."""
flags, peak = [], None
for s in stints:
if not s["miles"]:
continue
if peak and min(s["miles"]) < peak["miles"] - tolerance:
flags.append({"earlier": peak, "later": min(s["miles"]), "from": s["start"][:10], "state": s["state"]})
if not peak or max(s["miles"]) > peak["miles"]:
peak = {"miles": max(s["miles"]), "by": s["end"][:10], "state": s["state"]}
return flagsSet tolerance from your own review data, because small reversals look like typing: the documented sample for another VIN reads 112,160 miles on listings seen until Feb 26, 2025 and 112,158 on listings from Feb 27, 2025. A flag says the readings disagree and shows where; the cause can be a typo, a replaced instrument cluster, a cross-border listing entered in the wrong unit, or a rollback. Hops deserve the same care. Several stores of one group can list a car at once, so compare hop counts only after the attribution filter, and read the states on each stint, since a car that crosses state or national lines between stints is worth a second look for a lender.
The same stints serve a dealer watching for cars it sold, a use the docs describe directly: rerun the history for sold VINs on a schedule, and a new stint means the car is back on the market, with the new seller's city and state on the row. The documented reappearance sample shows the pattern, a car listed as new from Dec 08, 2023 that returns as used on Jun 25, 2025.
Unusual use: how long did this car sit last time
- Appraiser enters a trade-in VIN
- History by VIN
- Latest stint: days and asks
- Appraiser sets the number
An appraiser taking a car in trade wants to know whether it has been hard to sell before. The last stint answers that from the same code: how many days it lasted and how far the asking price moved, with its end date showing whether it is still live. In the sample, the latest stretch began with unpriced listings on Mar 06, 2025. The first asking price, 13,240, appeared on Mar 27, 2025, it fell to 12,370 on Jun 21, 2025 and to 11,822 on Jul 08, 2025, and the car was still listed on Jul 22, 2025. That is how the last retail attempt went, and it tells the appraiser how much holding time and price movement to allow for before making an offer. For a portfolio screen or a trade-in desk, the cost is one call per VIN plus one per extra page.
Limits to design around
- The VIN is not validated. A short VIN returns a 400, while a well-formed VIN with no history, or an invalid one, returns an empty array with a 200. Check length and the check digit yourself.
- Pages hold 50 rows. A short page is the last one, and asking past the end returns a 422.
- Sorting is by
status_dateonly, newest first by default, andsort_order=ascreverses it. - Naming
fieldsreplaces the default set, so list every column you need. - A listing can outlast the car's stay. The sample's unpriced Calgary listing ran from Oct 02, 2020 to Apr 29, 2025, weeks into the car's Idaho listings, so check for overlaps before you quote how long a stint lasted.
- Rows record what sellers advertised, so read them alongside title and odometer records. The history holds only the listings MarketCheck recorded.
- A 429 is either the per-second rate limit or the monthly quota. The
RateLimit-*andQuota-*headers tell them apart, andRetry-Aftersays how long to wait.
Who this is for
Lenders and insurers get evidence for collateral and claims reviews, and marketplace developers get the history panel. For appraisal and trade-in tools, the stint summary turns a VIN into a line such as "listed since Mar 06, 2025, two price cuts".
Start here
- Start on the Free tier for development. Plans are on the pricing page.
- Call the documented VIN, 5GTEN63L888227488, with
sort_order=ascand read the rows in order. - Add
is_searchableanddealer_idtofieldsand compare the stints with and without the attribution filter. - Keep the History by VIN reference open for the full field list.