NHTSA's vPIC decode is the default free source for U.S.-oriented VIN structure: you send a 17-character VIN, you get a flat row of manufacturer attributes. What newcomers miss is that a successful HTTP 200 is not the same as a complete vehicle card. Many fields arrive empty. Some Error Codes mean "we recognized the VIN pattern but cannot fill this attribute." Treating blanks as bugs - or inventing values to fill them - is how free tools lose trust.
This post covers why blanks happen, which fields are often incomplete, and a TypeScript pattern for honest empty-state UX.
A 200 response is not a full spec sheet
DecodeVinValuesExtended returns JSON with a Results array. For a typical call you read Results[0]. That object can contain Make, Model, ModelYear, BodyClass, EngineCylinders, PlantCity, ErrorCode, ErrorText, and dozens of other keys.
Important distinctions:
- Transport success means the API answered. It does not mean every marketing attribute exists for that VIN.
- Partial decode is normal. A WMI and year may resolve while trim, drive type, or fuel type stay blank.
- ErrorCode / ErrorText often travel with the same row. You can get useful Make/Model alongside codes that say some variables did not decode.
If your UI only checks res.ok and then renders every key as if it were present, users will see a wall of empty cells or, worse, your fallback guesses.
Why fields come back empty
Common, legitimate reasons:
- Manufacturer filing gaps. vPIC reflects what manufacturers filed for that WMI / model year structure. Not every optional attribute is populated for every year.
- Ambiguous VDS. Positions 4-8 do not always map one-to-one to a unique trim or options package in the public tables.
- Older or niche vehicles. Classic, gray-market, incomplete, or low-volume filings may decode thinly.
- Wrong expectation of "spec." Buyers want horsepower, exact option packages, and MSRP. Those are not guaranteed VIN-structure fields in a free public decode.
- Coverage limits. Strength is highest for vehicles relevant to the NHTSA vPIC dataset. Do not promise global completeness.
Product rule: empty means "unknown from this source," not "this car has no engine" and not "the API is broken."
Fields that often stay incomplete
| Area | Examples | UX note |
|---|---|---|
| Plant geography | PlantCity, PlantState, PlantCountry | Show only when present; never invent from plant letter alone |
| Powertrain detail | Displacement, cylinders, fuel | Prefer "not in decode" over guessed catalogs |
| Body / doors | BodyClass, Doors | Useful when filled; optional when blank |
| Series / trim | Series, Trim, Trim2 | Highest overclaim risk if you invent them |
Make, Model, and ModelYear are the usual spine. Even those can fail for bad input - which is why check-digit validation before the fetch still matters.
TypeScript: normalize blanks before you render
Do not pass raw vPIC strings straight into the UI. Normalize once:
type VinAttr = {
key: string;
label: string;
value: string | null;
};
const INTERESTING = [
["Make", "Make"],
["Model", "Model"],
["ModelYear", "Model year"],
["BodyClass", "Body"],
["DriveType", "Drive"],
["FuelTypePrimary", "Fuel"],
["PlantCity", "Plant city"],
["PlantCountry", "Plant country"],
["ErrorText", "Decode notes"],
] as const;
function clean(raw: unknown): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s || s === "0" || /^not\s*applicable$/i.test(s)) return null;
return s;
}
export function attrsFromVpicRow(row: Record<string, unknown>): VinAttr[] {
return INTERESTING.map(([key, label]) => ({
key,
label,
value: clean(row[key]),
}));
}
export function splitPresent(attrs: VinAttr[]) {
return {
known: attrs.filter((a) => a.value && a.key !== "ErrorText"),
missing: attrs.filter((a) => !a.value && a.key !== "ErrorText"),
notes: attrs.find((a) => a.key === "ErrorText")?.value ?? null,
};
}
Render known in a primary table, missing as a short "Not returned by decode" list (or omit), and notes in a muted callout. That layout teaches users that incompleteness is data quality, not a crash.
Honest UX patterns
1. Separate identity from options. Put Make / Model / Year in a hero block. Put optional specs in a secondary section labeled "Additional attributes (when available)."
2. Never invent trim. If Series and Trim are blank, do not scrape a marketing site to "complete" the card. Partial truth beats confident fiction.
3. Surface ErrorText once. Show decode notes near the results, not buried in a raw JSON expand.
4. Empty is not green. Do not show a "clear" badge because optional fields are blank. Blank plant city is not "no flood damage."
5. Disclose scope on the results page. One short line is enough: manufacturer attributes from public decode data; not a title, accident, or lien history.
6. Offer next steps that match the gap. If the buyer needs salvage brands or odometer trail, point them to a history report and a pre-purchase inspection - not to another free decode of the same VIN.
Minimal client flow
const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;
export async function decodeForUi(raw: string) {
const vin = raw.trim().toUpperCase();
if (!VIN_RE.test(vin)) {
return { ok: false as const, reason: "Invalid VIN format" };
}
const res = await fetch(
`https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValuesExtended/${vin}?format=json`
);
if (!res.ok) return { ok: false as const, reason: `vPIC HTTP ${res.status}` };
const row = ((await res.json()).Results?.[0] ?? {}) as Record<string, unknown>;
const parts = splitPresent(attrsFromVpicRow(row));
if (!parts.known.some((a) => a.key === "Make" || a.key === "ModelYear")) {
return {
ok: false as const,
reason: parts.notes ?? "VIN did not decode enough identity fields",
};
}
return { ok: true as const, vin, ...parts };
}
Cache carefully, respect rate limits, and do not log full VIN lists longer than you need.
Disclosure
I maintain VIN Lookup, a free manufacturer decode built around public NHTSA-style data. The product goal is clear attributes and clear gaps: show what the decode returns, leave blanks blank, and stay explicit that title history and inspections live outside this layer.
Takeaways
- vPIC blanks are expected; design for them.
- Normalize empty strings before render.
- Put ErrorText where humans will read it.
- Never fill trim or history from imagination.
- One honest scope line beats a decorative "complete report" label.
If you have shipped a decode UI, the interesting discussion is not how many columns you show - it is how you behave when half of them are empty.
Top comments (0)