Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

How to Call NHTSA Recalls by VIN from Your App (Without Guessing by Model Year)

If you build anything that touches used cars (a marketplace, a dealer tool, a fleet dashboard), sooner or later someone asks: "Does this car have an open recall?" The tempting answer is to look up recalls by make, model

If you build anything that touches used cars (a marketplace, a dealer tool, a fleet dashboard), sooner or later someone asks: "Does this car have an open recall?" The tempting answer is to look up recalls by make, model and model year. It is easy, and it is wrong often enough to matter.

This post walks through why model-year lookups mislead users, which NHTSA endpoints exist, and a practical TypeScript flow for a VIN-first recall check.

Why model year alone fails

Recalls are not issued "for the 2019 Civic." They are issued for a population of vehicles defined by the manufacturer, usually a VIN range, a build date window, a specific plant, or a specific supplier part. A few examples of what that means in practice:

  • Split production. A defect might affect cars built between March and August only. Two 2019 cars of the same model can have different recall status.
  • Plant-specific issues. The same model built in two plants can have different suppliers. Only one plant's VINs may be affected.
  • Trim and engine. A fuel pump recall might only hit one engine option.
  • Remedy status. Even if a VIN was in a recall population, the repair may already have been done. A model-year query cannot know that.

So a make/model/year query tells you "recalls exist for some vehicles like this one." That is useful research, but showing it to a buyer as "this car has 3 open recalls" is misleading in both directions.

The NHTSA endpoints you will see

NHTSA exposes a few public, free, keyless endpoints that are relevant:

  1. vPIC decode (https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValuesExtended/{VIN}?format=json): decodes a VIN into make, model, model year, plant, engine and more. Great for validation and for building the make/model/year tuple.
  2. Recalls by vehicle (https://api.nhtsa.gov/recalls/recallsByVehicle?make=...&model=...&modelYear=...): returns recall campaigns for a make/model/year. This is the "research" level.
  3. VIN-specific open recall status: NHTSA's public lookup at nhtsa.gov/recalls checks a VIN against manufacturer-reported data and shows unrepaired recalls. Manufacturers also provide their own VIN lookups. Check the current terms before automating anything against the consumer-facing VIN tool; for production use, the safest pattern is often to link the user to the official VIN lookup for the final answer.

The honest product design is: use vPIC to validate and decode, use recallsByVehicle to show campaigns that may apply, and clearly send users to a VIN-specific source for the definitive "open / remedied" answer.

A sample fetch flow (TypeScript)

const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;

type Decoded = { make: string; model: string; modelYear: string; plant?: string };

function checkDigitOk(vin: string): boolean {
  const map: Record<string, number> = {
    A:1,B:2,C:3,D:4,E:5,F:6,G:7,H:8,J:1,K:2,L:3,M:4,N:5,P:7,R:9,
    S:2,T:3,U:4,V:5,W:6,X:7,Y:8,Z:9,
  };
  const weights = [8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2];
  const sum = [...vin].reduce((acc, ch, i) => {
    const v = /\d/.test(ch) ? Number(ch) : map[ch];
    return acc + v * weights[i];
  }, 0);
  const r = sum % 11;
  return vin[8] === (r === 10 ? "X" : String(r));
}

async function decodeVin(vin: string): Promise<Decoded> {
  const res = await fetch(
    `https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValuesExtended/${vin}?format=json`
  );
  if (!res.ok) throw new Error(`vPIC ${res.status}`);
  const row = (await res.json()).Results?.[0] ?? {};
  if (!row.Make || !row.ModelYear) throw new Error("VIN did not decode");
  return { make: row.Make, model: row.Model, modelYear: row.ModelYear, plant: row.PlantCity };
}

async function candidateRecalls(d: Decoded) {
  const qs = new URLSearchParams({ make: d.make, model: d.model, modelYear: d.modelYear });
  const res = await fetch(`https://api.nhtsa.gov/recalls/recallsByVehicle?${qs}`);
  if (!res.ok) throw new Error(`recalls ${res.status}`);
  const body = await res.json();
  return (body.results ?? []).map((r: any) => ({
    campaign: r.NHTSACampaignNumber,
    component: r.Component,
    summary: r.Summary,
  }));
}

export async function recallCheck(rawVin: string) {
  const vin = rawVin.trim().toUpperCase();
  if (!VIN_RE.test(vin)) return { ok: false, reason: "Invalid VIN format (no I, O, Q; 17 chars)" };
  if (!checkDigitOk(vin)) return { ok: false, reason: "Check digit mismatch, likely a typo" };

  const decoded = await decodeVin(vin);
  const campaigns = await candidateRecalls(decoded);
  return {
    ok: true,
    vin,
    decoded,
    campaigns, // "may apply" - not confirmed for this VIN
    confirmUrl: "https://www.nhtsa.gov/recalls", // send user here for VIN-specific status
  };
}

Two things worth noting in the UI layer: label campaigns as "recalls for this model that may apply," and put a clear "Check this exact VIN" button next to it.

Edge cases you will hit

  • Check digit rules are North America only. Position 9 is enforced for US/Canada VINs. Many European VINs do not use it, so do not hard-reject non-NA VINs; warn instead.
  • Model name mismatches. vPIC's Model string does not always match the recalls API's model naming (e.g. trims, hyphens, "Pickup" suffixes). Normalize, and fall back to listing models for that make/year if the query comes back empty.
  • Pre-1981 and short VINs. The 17-character standard started with model year 1981. Older vehicles will not decode.
  • Rate limits and outages. Both APIs are free and occasionally slow. Cache decodes by VIN (they never change) and cache recall lists per make/model/year for a few hours.
  • Empty results are not "safe." An empty list can mean no campaigns, a naming mismatch, or an API hiccup. Say "no recalls found for this model" rather than "no recalls."
  • Remedied recalls. Only the VIN-specific lookup knows whether a repair was completed. Never mark a car as "has open recalls" from model-level data.
  • Imports and gray-market vehicles. Vehicles not originally sold in the US may decode partially and have no NHTSA recall coverage at all.

Wrapping up

The pattern that has worked best for me is VIN first, model second, official source last: validate and decode the VIN, show model-level campaigns as candidates with honest wording, and link to the VIN-specific lookup for the final answer. Users get something useful immediately, and you avoid telling anyone a car is fine (or broken) based on its model year.

Transparency note: I maintain VIN Lookup, a free VIN decoder built on NHTSA vPIC data, and the flow above is close to what it uses. Not affiliated with NHTSA.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.