VIN CheckTitle HistoryRecallsREST API

Catch the salvage brand and the open recall before anyone signs

Four MarketCheck endpoints turn a VIN or a license plate into title history and recall records you can act on, for a VDP, a loan file, an insurance quote or a service-lane list.

MarketCheck5 min read

For developers at dealers, lenders, insurers and car marketplaces

A used car can carry problems that only show up in records: a title brand from another state, a salvage or total-loss report, an odometer reading below the one on an earlier title, a recall nobody fixed. Each one changes what the car is worth and what a buyer has to be told.

MarketCheck puts four checks behind one API key. Three come from partners with terms of their own (VINData, AutoRecalls and CarsXE), and the fourth is MarketCheck's Basic VIN Decoder, which tells you whether a VIN is real before you pay for anything else.

In short. Decode first, and stop when is_valid is false. Read the title with VINData, calling access-report before generate-report, because every generated report is billed. Pull recall records with AutoRecalls, and use the CarsXE plate decoder when a shopper knows the plate but not the VIN. Accept the partner agreements in the portal before your first call. Code raises the flags, and a person makes the decision.

Four checks, one question each

Question Endpoint Fields to read
Is the VIN real, and what car is it? GET /v2/decode/car/{vin}/specs is_valid, year, make, model, trim
Does the title carry a brand or a loss? GET /v2/vindata/access-report/aamva/{vin} titleBrandReported, junkSalvageTotalLoss, titleInformation
Are there recalls on it? GET /v2/car/autorecalls/{vin} data.items[].recalls
Which car wears this plate? GET /v2/carsxe/plate-decoder/{plate} vin, year, make, model

The title report is the one to read closely. The docs describe it as NMVTIS data covering title information, odometer readings, brand history and salvage records. Here is the documented access-report sample for a 2012 Ford Focus, trimmed to two of its four title events and without the summary message:

Response (trimmed)json
{
  "productName": "NMVTIS",
  "titleInformation": [
    { "date": "2016-04-21T00:00:00Z", "state": "California", "type": "Current",
      "reportedOdometer": "100,720", "measure": "M", "event": "Title issued", "vinChanged": false },
    { "date": "2016-02-17T00:00:00Z", "state": "California", "type": "Historical",
      "reportedOdometer": "56,705", "measure": "M", "event": "Title issued", "vinChanged": false }
  ],
  "titleBrandReported": [],
  "junkSalvageTotalLoss": [],
  "odometerInformation": [],
  "summary": { "make": "FORD", "model": "Focus", "year": 2012, "type": "car", "empty": false },
  "reportSummary": { "color": "green" }
}

Empty arrays are the clean result: the schema says titleBrandReported and junkSalvageTotalLoss come back empty when there is nothing to report. The types differ between sections, too: the odometer on a title event is formatted text ("100,720"), while odometerInformation carries it as a number.

One function for the whole check

vehicle-check.tsts
const BASE = 'https://api.marketcheck.com/v2'

async function get(path: string, params: Record<string, string> = {}) {
  const url = new URL(BASE + path)
  url.searchParams.set('api_key', process.env.MARKETCHECK_API_KEY ?? '')
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
  const res = await fetch(url)
  // Error bodies are { code, message }, except on 502 and 503
  return { status: res.status, body: res.ok ? await res.json() : null }
}

// Reading a stored report costs less than generating one, and generated reports expire after 90 days
async function titleReport(vin: string) {
  const stored = await get(`/vindata/access-report/aamva/${vin}`)
  if (stored.status === 200) return stored.body
  if (stored.status !== 422) throw new Error(`access-report returned ${stored.status}`)
  const fresh = await get(`/vindata/generate-report/aamva/${vin}`) // billed per report
  if (fresh.status !== 200) throw new Error(`generate-report returned ${fresh.status}`)
  return fresh.body
}

export async function checkVin(vin: string, statedMiles?: number) {
  const decoded = await get(`/decode/car/${vin}/specs`)
  if (decoded.status !== 200 || !decoded.body.is_valid) return { vin, flags: ['VIN does not decode'] }

  const title = await titleReport(vin)
  const flags: string[] = []
  for (const b of title.titleBrandReported) flags.push(`title brand: ${b.brand} (${b.state}, ${b.date})`)
  for (const j of title.junkSalvageTotalLoss) flags.push(`junk, salvage or total loss: ${j.disposition} (${j.date})`)

  // Title events carry the odometer as text with a thousands separator, e.g. "100,720"
  const titled = title.titleInformation.map((t: { reportedOdometer: string }) => Number(t.reportedOdometer.replace(/,/g, '')))
  const highest = Math.max(0, ...titled.filter(Number.isFinite))
  if (statedMiles !== undefined && statedMiles < highest) flags.push(`stated ${statedMiles} miles, but a title event reads ${highest}`)

  const recalls = await get(`/car/autorecalls/${vin}`)
  const records = (recalls.body?.data?.items ?? []).flatMap((item: { recalls?: unknown[] }) => item.recalls ?? [])
  if (records.length) flags.push(`${records.length} recall record(s) to review`)

  const { year, make, model } = decoded.body
  return { vin, car: `${year} ${make} ${model}`, summary: title.reportSummary, flags }
}

The access call returns a 422 when no report exists for the VIN. The docs say generated reports expire after 90 days, so the same fallback also replaces an expired report, and they warn that each generation is charged. The function returns flags for a person to weigh: whether a brand or a mileage gap ends a deal is their decision.

Title brands and recalls on a VDP

  1. Used unit enters inventory
  2. Decode and title report
  3. Recall records
  4. Used-car manager reviews
  5. VDP disclosure goes live

Run the check when a unit enters inventory, and hold the VDP disclosure until the used-car manager has looked at anything flagged. Two details shape the display. The portal's 3rd-party agreements page describes each provider's terms as downstream-use terms, accepted once before you can call its endpoint, so read them before you show partner data to shoppers. The 90-day expiry also sets a refresh date for a unit that sits.

Pre-loan checks in underwriting

  1. Loan application (VIN, stated miles)
  2. Decode matches the application
  3. Title brands and odometer history
  4. Your credit policy
  5. Underwriter decides

A lender runs the same function with the stated mileage from the application:

  1. The decode should match the year, make and model on the application.
  2. The highest odometer reading on a title event sets a floor for the stated miles.
  3. Brands and loss records go against your credit policy, in code.
  4. The underwriter decides every file that raised a flag.

The docs list scheduled NMVTIS downtime, daily from 6:00 to 7:00 AM UTC and on Sundays from 6:00 to 10:00 AM UTC. Queue any check that lands in those windows and run it once the window closes, so an application waits instead of failing.

Unusual: plate to VIN in an insurance quote

When a shopper has the plate but not the VIN, the CarsXE decoder turns the plate plus a two-letter state, required for US and Canadian plates, into the VIN and the basics of the car.

  1. Shopper enters plate and state
  2. Plate decoder
  3. Shopper confirms the car
  4. Basic decode for rating fields
  5. Quote engine

Here is the documented sample for a California plate, trimmed:

Response (trimmed)json
{
  "success": true,
  "input": { "plate": "7XER187", "state": "CA", "country": "US" },
  "vin": "3KPFK4A78HE103497",
  "year": "2017",
  "make": "Kia",
  "model": "Forte",
  "trim": "LX",
  "registration_year": "2017",
  "body_style": "Sedan"
}

Show the shopper the decoded car and ask them to confirm it before you rate it. Then run the VIN through the Basic VIN Decoder for the fields a rating engine wants, such as body type and drivetrain. Note that year arrives as a string here, while the decoder returns a number. A 422 means the plate was not found, which is the moment to ask for the VIN instead.

Unusual: a recall-aware service-lane list

A dealer's DMS holds the VINs of the cars it has sold or serviced. Run those VINs through AutoRecalls on a schedule and the service department gets a list of customers worth a call, grouped by campaign.

  1. Weekly
  2. Customer VINs from the DMS
  3. Recall records per VIN
  4. Group by campaign
  5. Service manager approves outreach
  6. Your CRM sends

The docs say AutoRecalls returns open and closed recalls, plus OEM service campaigns when available. Each recall carries nhtsaNumber, recallTitle, remedyDescription, recallLaborHours and recallPartsCost, enough for a service advisor to plan the visit.

The docs publish the schema but no sample response, and they do not list the values of status or recallState. Log your first few responses and map those values before you decide which records count as open. Decide separately what to do with filteredRecalls, which carry a filterReason, and with recalls marked isExcluded. The service manager approves the list, and your CRM sends it under the contact rules you already follow.

Limits to design around

  • Accept the partner terms first. Each provider's agreement is on the 3rd-party API agreements page in the portal, and calls to its endpoint wait on that acceptance.
  • Generated title reports are billed. Call access-report first, and expect a 422 when no report exists or an old one has expired.
  • NMVTIS has downtime windows. Keep batch checks out of them and retry a 503 later.
  • 502 and 503 bodies differ. Most error bodies are { code, message }, and the docs exclude 502 and 503 responses from that format.
  • Full VINs only. The Basic VIN Decoder does not accept squish VINs and returns a 422 when a decode is not possible. For installed options, use the NeoVIN decoder.
  • Plates need a state. state is two letters and required for US and Canadian plates, and country defaults to us.
  • Recall statuses are yours to map. No documented sample exists, so build the mapping from your own first responses.
  • A record check is not an inspection. A clean report means no warning events were reported, and the summary message in the documented sample still recommends an inspection by a qualified mechanic.

Who this is for

Dealer-website and inventory vendors can put title and recall disclosures on a VDP. Loan-origination developers at lenders and credit unions can add the check to underwriting, and insurers can put the plate lookup at the top of a quote form. Service-lane and CRM developers get the recall list.

Start here

  1. Start on the Free tier for development, and check the pricing page for the plans that include the partner reports.
  2. Sign in and accept the AutoRecalls, VINData and CarsXE agreements on the 3rd-party API agreements page.
  3. Decode the documented sample VIN, 1FAHP3F28CL148530, and compare the result with the 2012 Ford Focus SE in the docs.
  4. Keep the VINData, AutoRecalls, CarsXE and Basic VIN Decoder pages open while you build.

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