History by VINREST APILendersAppraisal

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. fields picks the columns, including optional ones such as dealer_id, dom_active and is_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:

Response (trimmed)json
[
  { "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

  1. Shopper opens a car
  2. History by VIN
  3. Collapse copies into events
  4. 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

  1. Application or claim
  2. History by VIN
  3. Compare stated miles and price
  4. 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

  1. VINs to screen
  2. Every page of history
  3. Stints by attributed dealer
  4. Flags with the evidence
  5. 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.

vin_history.pypython
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 flags

Set 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

  1. Appraiser enters a trade-in VIN
  2. History by VIN
  3. Latest stint: days and asks
  4. 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_date only, newest first by default, and sort_order=asc reverses it.
  • Naming fields replaces 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-* and Quota-* headers tell them apart, and Retry-After says 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

  1. Start on the Free tier for development. Plans are on the pricing page.
  2. Call the documented VIN, 5GTEN63L888227488, with sort_order=asc and read the rows in order.
  3. Add is_searchable and dealer_id to fields and compare the stints with and without the attribution filter.
  4. Keep the History by VIN reference open for the full field list.

Start building on the Free tier.

The Free tier is for development and testing, on the same endpoints you will use in production. When you go live, pick a plan on the pricing page.

More use cases

All use cases