NeoVINVIN decodingBatch APILenders

The build behind the VIN: trim, installed options and window-sticker MSRP

What NeoVIN returns for one VIN, how to turn it into merchandising, window-sticker style pricing and F&I prefill, and how to decode a whole portfolio in batch jobs or catch listings with the wrong trim.

MarketCheck5 min read

For developers at dealers, marketplaces, lenders and F&I providers

A basic VIN decode tells you year, make and model. Most of what buyers and lenders care about sits one level down: the trim, the packages installed at the factory, the colors by their manufacturer names, and what the car stickered for. MarketCheck's NeoVIN decoder, GET https://api.marketcheck.com/v2/decode/car/neovin/{vin}/specs, returns that from a 17-character VIN, and a set of batch endpoints decodes thousands of VINs from one file.

In short. One call returns trim and version, installed options with codes and prices, colors, features and an MSRP breakdown in which combined_msrp is base MSRP plus delivery plus installed options. Cache each decode by VIN and sum option sale_price values to reconcile totals. The batch jobs take over when the list runs into the thousands.

What one decode returns

This is the documented sample for VIN 1FTEW1C59LKE56394, trimmed to two of its eighteen installed options and one feature group:

Response (trimmed)json
{
  "vin": "1FTEW1C59LKE56394",
  "squish_vin": "1FTEW1C5_LK",
  "year": 2020,
  "make": "Ford",
  "model": "F-150",
  "trim": "XLT",
  "trim_confidence": "mc_build_specs|cf_equipments|exact_match",
  "version": "XLT SuperCrew 5-1/2 Box",
  "drivetrain": "RWD",
  "engine": "5.0L V8",
  "msrp": 40020,
  "delivery_charges": 1695,
  "installed_options_msrp": 10085,
  "combined_msrp": 51800,
  "exterior_color": { "code": "E7", "name": "Velocity Blue Metallic", "base": "Blue", "confidence": "0.74" },
  "options_packages": "302A,53A,55B,59S,63T,862,995,XL9,153,17B,50N,655,68X,64S,54R,86B,85P,435",
  "installed_options_details": [
    { "code": "302A", "name": "Equipment Group 302A - Luxury", "msrp": "4345", "type": "P",
      "confidence": "High", "verified": true, "rule": "Discount", "sale_price": "2595" },
    { "code": "53A", "name": "Trailer Tow Package", "msrp": "995", "type": "P",
      "confidence": "High", "verified": true, "rule": "Excludes", "sale_price": "995" }
  ],
  "high_value_features": {
    "302A": [
      { "category": "Infotainment", "description": "Satellite Radio" },
      { "category": "Interior", "description": "Heated Seats" }
    ]
  }
}

The price fields line up the way the NeoVIN data dictionary defines them. msrp is the base price of that version with no options, and combined_msrp is base MSRP plus delivery_charges plus installed_options_msrp: 40,020 plus 1,695 plus 10,085 is 51,800 in this sample. Features, high-value features and installed equipment are keyed by the option code that brought them, with base equipment under STANDARD, so you always know which package a feature came from.

Two identity fields are worth storing next to the VIN. version is finer than trim (here it adds the cab and bed), and squish_vin keeps the VIN characters that describe the vehicle's pattern (the first eight plus the model year and plant), so cars built to the same basic pattern share it and it works as a grouping key.

Merchandising that matches the build

  1. New unit in the feed
  2. NeoVIN decode
  3. VDP specs, badges and filters
  4. Merchandiser approves the template

A vehicle detail page needs the packages and features that sell, in terms a shopper can filter on. One decode covers them:

  • high_value_features is the short list of valuable equipment, which suits badges and search filters.
  • features and installed_equipment give the full equipment list for a specs tab, grouped by option code.
  • exterior_color and interior_color carry the manufacturer's color name and code plus a base color for filters.
  • installed_options_details names each package and option with its code, type and price.

Inventory Search filters on the same codes through its options_packages and high_value_features parameters, so the page and your search filters agree about what the car has.

Window-sticker style pricing

To show where the price came from, build the summary from the decode and refuse to print one whose parts do not add up.

  1. VIN from the DMS
  2. NeoVIN decode
  3. Sticker summary that reconciles
  4. Manager checks before it prints

Label the output as decoded data, because it is a reconstruction of the build, and the car's original window sticker remains the authority.

sticker.tsts
interface InstalledOption { code: string, name: string, msrp: string, rule: string, sale_price: string }

export async function decode(vin: string) {
  const url = new URL(`https://api.marketcheck.com/v2/decode/car/neovin/${vin}/specs`)
  url.searchParams.set('api_key', process.env.MARKETCHECK_API_KEY ?? '')
  const res = await fetch(url)
  if (res.status === 422) return null // invalid or undecodable VIN: fall back to your own data
  if (!res.ok) throw new Error(`neovin returned ${res.status}`)
  return res.json()
}

export function stickerSummary(d: any) {
  const options = (d.installed_options_details ?? []) as InstalledOption[]
  // sale_price is what each option adds after package rules; msrp is its standalone price
  const optionsTotal = options.reduce((sum, o) => sum + Number(o.sale_price), 0)
  return {
    title: `${d.year} ${d.make} ${d.model} ${d.trim}`,
    version: d.version,
    colors: { exterior: d.exterior_color?.name, interior: d.interior_color?.name },
    base: d.msrp,
    options: options.map(o => ({ code: o.code, name: o.name, price: Number(o.sale_price), list: Number(o.msrp), rule: o.rule })),
    destination: d.delivery_charges,
    total: d.combined_msrp,
    // Hold the summary for review when the parts do not add up
    reconciles: optionsTotal === d.installed_options_msrp
      && d.msrp + d.delivery_charges + d.installed_options_msrp === d.combined_msrp
  }
}

In the F-150 sample, the eighteen options' msrp values add up to 16,640, while their sale_price values add up to 10,085, which is exactly installed_options_msrp. Packages such as 302A carry a Discount rule and a sale_price below their msrp, so summing msrp overstates the options.

F&I menus and lending forms

  1. Deal created
  2. NeoVIN decode
  3. Prefill trim, engine, drivetrain and MSRP
  4. F&I manager reviews

F&I menus and lender applications both ask for the vehicle's build. Decode once when the deal is created and prefill the form from the response. Store the whole response with the deal, so a later audit can see what the form was filled from; decode_version and updated_at date the decode itself.

Decoding a lender's whole portfolio

  1. Portfolio VINs from the loan system
  2. Batch jobs of up to 3,000 VINs
  3. Decoded builds and a failure queue
  4. Portfolio analyst reviews failures

A lender that wants trim, options and MSRP on every car it finances has thousands of VINs to decode. The Batch API takes a plain CSV of 100 to 3,000 VINs per job and, once the job finishes, returns a gzip JSONL file with one decode per line. Batch is enabled on your account on request; the docs list it as an Enterprise package offering. Accounts have a limit on active decode jobs, so the script waits for each job to finish before it submits the next:

portfolio_decode.pypython
import csv, gzip, hashlib, io, json, math, os, time
import requests

JOBS = "https://api.marketcheck.com/v2/batch/neovin/decode/jobs"
KEY = {"api_key": os.environ["MARKETCHECK_API_KEY"]}

def submit(vins, reference):
    body = io.StringIO()
    csv.writer(body).writerows([["vin"]] + [[v] for v in vins])  # a vin header, plain CSV
    r = requests.post(JOBS, params=KEY, timeout=120,
                      headers={"Idempotency-Key": reference},  # a retried submit returns the same job
                      files={"file": ("vins.csv", body.getvalue(), "text/csv")},
                      data={"client_reference": reference})
    r.raise_for_status()
    return r.json()["job_id"]

def wait(job_id):
    while True:
        job = requests.get(f"{JOBS}/{job_id}", params=KEY, timeout=60).json()
        if job["status"] != "PROCESSING":
            return job
        time.sleep(60)  # the documented polling interval; a webhook_url avoids polling

def download(job_id):
    r = requests.post(f"{JOBS}/{job_id}/download", params=KEY, timeout=300)  # 1 of 3 downloads
    r.raise_for_status()
    if hashlib.sha256(r.content).hexdigest() != r.headers["X-File-Checksum"].lower():
        raise ValueError("checksum mismatch")
    with open(f"{job_id}.jsonl.gz", "wb") as f:  # results expire 30 days after the job ends
        f.write(r.content)
    return [json.loads(line) for line in gzip.decompress(r.content).splitlines()]

def decode_portfolio(vins, prefix):
    if len(vins) < 100:
        raise ValueError("under 100 VINs, use the single-VIN endpoint")
    size = math.ceil(len(vins) / math.ceil(len(vins) / 3000))  # even jobs, none under 100
    decoded, failed = [], []
    for n, start in enumerate(range(0, len(vins), size)):
        job = wait(submit(vins[start:start + size], f"{prefix}-{n}"))  # change prefix to resubmit
        if job["status"] == "FAILED":
            raise RuntimeError(f'{job["error_code"]}: {job["error_message"]}')
        for row in download(job["job_id"]):
            (decoded if row["http_status_code"] == 200 else failed).append(row)
    return decoded, failed

Each output line carries its own http_status_code: 200 for a decode, 400 for an invalid VIN, 422 for one that could not be decoded (with a reason), and 500 for a processing error. The failure list is the portfolio team's queue for VINs that were keyed wrong in the loan system. The decoded rows let the team segment the book by trim, powertrain, drivetrain and installed packages, and give every car the same reference for its original sticker price, combined_msrp, independent of the figures on each contract. If you register a webhook_url with a webhook_secret, verify the X-Webhook-Signature header (HMAC-SHA256 over the timestamp and the raw body) and reject anything older than five minutes.

Catching mis-trimmed listings

Marketplaces and dealer groups can check the trim a dealer typed against the decode.

  1. Listings with the trim the dealer typed
  2. NeoVIN decode per VIN
  3. Compare trim and claimed features
  4. Merchandiser fixes or confirms

The check runs in four steps:

  1. Decode each VIN once and cache it; a VIN's factory build does not change.
  2. Compare the listing's trim with trim after normalizing case and spacing. When they differ, compare it with version too, because a listing may carry the version string as its trim.
  3. Check claimed equipment against high_value_features and features. A listing that advertises heated seats on a build without them is worth a second look.
  4. Send each disagreement to a person with both values and the trim_confidence string, which records how the decoder chose the trim.

When a listing claims a package the decode did not find, GET /v2/decode/car/neovin/{vin}/options-packages lists every package that was available for that VIN when it was sold, with codes, names and MSRP. That narrows the review to packages that could have been fitted, which a person can then confirm on the car or its original sticker.

Limits to design around

  • 422 on the single endpoint means an invalid VIN or a failed decode. With include_generic=true, generic specifications come back as a fallback when a full decode is not possible.
  • Full VINs only. The decoder does not accept squish VINs.
  • Installed versus available. include_available_options=true adds packages that could have been ordered; keep them out of the installed list.
  • Mixed confidence formats. Color confidence is a number in a string, option confidence reads High or Low, verified can be null, and trim_confidence describes a method.
  • Batch files. Plain CSV with a vin header (gzip is rejected), 10 MB at most, and 100 to 3,000 VINs. A file outside that range is accepted with a 202 and fails during processing.
  • Batch jobs. Submitting past your limit on active jobs returns 409 active_job_exists. Poll no faster than every 60 seconds, and save the file on the first download: each job allows three, and results are kept 30 days after the job ends.
  • 429s. Honor Retry-After, and read Quota-* headers to tell the monthly quota from the per-second limit.

Who this is for

Developers at dealer groups and marketplaces building VDP content and listing checks, and at lenders and F&I providers who need the exact build behind every deal and every loan on the book.

Start here

  1. Create a free account and copy your API key. Start on the Free tier for development; current plans are on the pricing page.
  2. Decode a VIN from your own lot and compare the result with its window sticker.
  3. Run stickerSummary on a few decodes and check that each one reconciles.
  4. Read the NeoVIN Decoder docs, including the batch section, before planning a portfolio run.

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