Vehicle detail pages that notice when the listing behind them ends
Build vehicle detail pages on MarketCheck Car Listing Details, then use the same call to catch changes between pulls and to keep one record per car across sources.
MarketCheck5 min read
For developers building vehicle detail pages, listing monitors and aggregation pipelines
Car Listing Details, GET /v2/listing/car/{listing_id}, returns one dealer listing in full: price and mileage with their reference values, photos, options and features, the seller's comments, the dealer, and the decoded build. It is the follow-up call to a search, since Inventory Search and History by VIN both return listing IDs, and it answers for any listing ID, whether the listing is live or long gone.
That last property is useful, and it is also the trap. A listing ID names one version of a car's listing at one source. When the dealer changes the price or the mileage, MarketCheck expires that listing and starts a new one with a new ID, and the old ID keeps answering with the old price. A detail page that stores IDs will show a stale price unless it checks.
In short. Fetch details with the ID a search gave you, render from
build,media,extraanddealer, and treat the ID as a snapshot. When itslast_seen_atstops moving, the listing has ended, and a search on the VIN shows where the car went.
What one listing returns
This is the documented sample, trimmed to the fields discussed here. The dealer name, address, photo URLs from the dealer's site and seller comments are removed:
{
"id": "5TFUW5F16KX782196-44367fcc-c2fb",
"vin": "5TFUW5F16KX782196",
"heading": "Used 2019 Toyota Tundra SR5",
"price": 27995,
"ref_price": 28889,
"ref_price_dt": 1752653949,
"price_change_percent": -3.09,
"miles": 80203,
"dom_active": 83,
"last_seen_at_date": "2025-07-22T02:58:28.000Z",
"first_seen_at_date": "2025-07-17T06:10:29.000Z",
"first_seen_at_source_date": "2025-05-02T06:14:51.000Z",
"first_seen_at_mc_date": "2018-09-08T10:46:10.000Z",
"media": {
"photo_links_cached": [
"https://api.marketcheck.com/v2/image/cache/car/5TFUW5F16KX782196-44367fcc-c2fb/e93cff16913e9d2a75f3197d3ba15c7d?api_key=YOUR_API_KEY"
]
},
"extra": {
"options": ["SR5 Package", "Rear Under-Seat Storage Compartment"],
"features": ["Lane departure", "Wireless phone connectivity"],
"high_value_features": [
{ "category": "Safety & Driver Assist", "description": "Anti Collision System", "type": "Standard" }
],
"options_packages": ["RE", "SP"]
},
"mc_dealership": { "mc_location_id": 1391837, "mc_rooftop_id": 517078 },
"build": { "year": 2019, "make": "Toyota", "model": "Tundra", "trim": "SR5", "version": "SR5 5.7L 4WD FFV Double Cab Std Bed" }
}Read the dates together and they match the lifecycle rules. The truck has been on this dealer's site since May 02, 2025 (first_seen_at_source_date), and MarketCheck first saw the VIN on Sep 08, 2018 (first_seen_at_mc_date). The listing itself only began on Jul 17, 2025, a day after its reference price was recorded: the dealer cut the asking price from 28,889 to 27,995, and the cut started this listing.
Two blocks here never appear in an Inventory Search result, extra (options, features, seller comments, high-value features and option package codes) and car_location, so they are what the extra call buys you.
A vehicle detail page from one ID
- Shopper opens a car
- Your detail route
- Car Listing Details
- Photos, specs and dealer card
Fetch details when a shopper opens a car. A results page can run on search rows alone, which already carry price, mileage, photos and the dealer, while each details fetch is a call. Build the title and spec table from build, because heading is the dealer's own text. Plan for every block to be optional: in the documented feed-integration sample, the listing has no miles key and no car_location, and its extra holds only high-value features and option package codes.
Photos come in two lists. media.photo_links are the dealer's own URLs, and media.photo_links_cached are MarketCheck's copies, which the docs say exist only for used cars. The cached URLs carry your key by default and fall under a private caching policy that rules out public CDNs, so serve them through your own server.
Each high-value feature carries a category, a description and a type (Standard or Optional in the sample), which suits a short highlights strip. The full feature list is long (the untrimmed sample has 127 entries), so group or collapse it for display. For a "price reduced" badge, compare price with ref_price, the previous asking price at the same source, which ref_price_dt dates.
const BASE = 'https://api.marketcheck.com/v2'
const ENDED_AFTER_DAYS = 3 // a car absent longer than this returns under a new ID
export async function vehicleDetail(listingId: string) {
// listing_id is case-sensitive, so pass it exactly as the search returned it
const res = await fetch(`${BASE}/listing/car/${listingId}?api_key=${process.env.MARKETCHECK_API_KEY}`)
if (res.status === 422) return null // not a valid listing ID
if (!res.ok) throw new Error(`Car Listing Details returned ${res.status}`)
const l = await res.json()
const b = l.build ?? {}
const daysSinceSeen = (Date.now() / 1000 - (l.last_seen_at ?? 0)) / 86400
return {
title: [b.year, b.make, b.model, b.trim].filter(Boolean).join(' ') || l.heading,
price: l.price, // may be absent
miles: l.miles, // may be absent
photos: l.media?.photo_links ?? [],
cachedPhotos: l.media?.photo_links_cached?.length ?? 0, // proxy these, never send them to the browser
highlights: (l.extra?.high_value_features ?? []).map((f: { description: string }) => f.description),
features: l.extra?.features ?? [],
comments: l.extra?.seller_comments ?? '',
specs: { version: b.version, engine: b.engine, drivetrain: b.drivetrain, fuel: b.fuel_type },
dealer: l.dealer && { name: l.dealer.name, phone: l.dealer.phone, city: l.dealer.city, state: l.dealer.state },
// Silent this long, the ID cannot resume: look up the VIN for its successor
ended: daysSinceSeen > ENDED_AFTER_DAYS
}
}The same function serves a CRM or lead-routing tool: when a lead arrives from a detail page, store the listing ID with the lead and attach the options and features for the salesperson.
Unusual use: change detection between two pulls
- Daily, after 11:00 UTC
- Details for each saved ID
- Compare with the last pull
- VIN search when an ID stalls
- Update or retire the page
Because price and mileage cannot change under one ID, a second pull of the same ID has only two interesting outcomes. Either last_seen_at moved forward, which means the listing is alive at the same price and mileage, and any other difference, such as a different photo count, is a content update to re-render. Or it did not move, which means the listing has ended and you need to find out why.
For that, search active listings on the VIN with nodedup=true, which returns every current listing of the car, duplicates included. A listing at the same source means the dealer changed the price or mileage (its ref_price shows the old price), and listings only at other sources mean the car is now advertised elsewhere. If nothing comes back, the car is off the market or briefly offline, and the lifecycle guide settles which after three days: a car that reappears after more than three days gets a new listing, so an ID silent that long is finished.
import hashlib
import os
import time
import requests
BASE = "https://api.marketcheck.com/v2"
KEY = os.environ["MARKETCHECK_API_KEY"]
THREE_DAYS = 3 * 86400
def get(path, **params):
r = requests.get(BASE + path, params={"api_key": KEY, **params}, timeout=60)
r.raise_for_status() # retry 429s as your client does elsewhere
return r.json()
def snapshot(listing):
extra, media = listing.get("extra", {}), listing.get("media", {})
return {
"id": listing["id"],
"source": listing.get("source"),
"last_seen_at": listing.get("last_seen_at", 0),
"price": listing.get("price"),
"miles": listing.get("miles"),
"photos": len(media.get("photo_links", [])),
"comments": hashlib.sha1(extra.get("seller_comments", "").encode()).hexdigest(),
}
def check(saved, vin):
now = snapshot(get(f"/listing/car/{saved['id']}"))
if now["last_seen_at"] > saved["last_seen_at"]:
changed = [k for k in ("photos", "comments") if now[k] != saved[k]]
return "live", now, changed
# This ID has stopped updating, so look for the car's current listings
found = get("/search/car/active", vin=vin, nodedup="true", rows=50).get("listings", [])
same_source = [l for l in found if l.get("source") == saved["source"]]
if same_source:
return "replaced", snapshot(get(f"/listing/car/{same_source[0]['id']}")), []
if found:
return "elsewhere", {"sources": sorted({l.get("source") for l in found})}, []
if time.time() - now["last_seen_at"] <= THREE_DAYS:
return "missing", now, []
return "gone", now, []Key your own pages by VIN and source, with the current listing ID as an attribute, so a replacement updates the page in place and a stored ID never strands it. And compare a Listing Details pull only with an earlier Listing Details pull. The docs note that this endpoint reads MarketCheck's main database as the crawl progresses, so its last_seen_at and first-seen fields can differ from the same listing in a search response.
Unusual use: one record per car across sources
- Listing IDs from several feeds
- Details for each ID
- Group by VIN
- Deduplicated VIN search
- Keep the attributed copy
Aggregators end up holding several IDs for one car: the dealer's own site plus copies on group sites and other sources. The VIN is the join key, so group by vin first. To choose which copy to keep, ask MarketCheck: a VIN search with default deduplication returns only the listing its attribution rules assign to the dealer with the car on its lot. Keep that copy and mark the rest as syndicated duplicates. For the past, History by VIN returns is_searchable per listing when you name it in fields, which marks the attributed copies among a car's old listings too.
When the attributed listing is not among the IDs you hold, Listing Details still helps. Copies whose mc_dealership.mc_location_id matches the attributed listing's location point at the same place, and the rest may be stale copies worth a second look. Leave listings without a VIN out of this process. The docs describe them as lower-quality records that are hard to normalize, and there is no reliable key to group them by.
Gotchas
listing_idis case-sensitive, and an invalid one returns a 422.- An expired ID still returns its last state, price included. Check
last_seen_atbefore showing a price from a stored ID. - Missing values are left out of the response, so treat every field and block as optional.
- Cached photo URLs carry your key unless you strip it. Keep them on your server and out of public caches.
- Details cost a call each. Fetch on demand for detail pages, and batch the daily change check after the 11:00 UTC data update.
- A 429 means a per-second rate limit or a spent monthly quota. The
RateLimit-*andQuota-*headers tell them apart, andRetry-Aftersays how long to wait.
Who this is for
Marketplace and classifieds developers get the detail page. Teams that mirror listings into their own systems, from lead-routing tools to data warehouses, get the change check and the de-duplication rules, which keep one current record per car.
Start here
- Start on the Free tier for development. Plans are on the pricing page.
- Fetch details for an
idfrom any Inventory Search response and compare them with the search row:extraandcar_locationare the new parts. - Save that pull and diff it against tomorrow's pull of the same ID.
- Read the Car Listing Details reference and the listing lifecycle guide, which explains when IDs change.