Tutorials
Decode a VIN with an API call
A vehicle identification number packs the manufacturer, the vehicle type, the model year and the plant into 17 characters. This tutorial walks through what each position means, how the ninth character is calculated, and how to decode a VIN with one request to the VIN decoder. You will end with a short function you can drop into a listing form or an intake script.
How a VIN is laid out
US federal rules set the content of a VIN in 49 CFR 565.15 1. It has four sections: three characters, then five, then one, then eight. This post calls the first three the WMI (world manufacturer identifier), positions 4 to 8 the VDS (vehicle descriptor section) and positions 10 to 17 the VIS (vehicle identifier section). Those labels are shorthand, and the regulation itself speaks of sections 1.
| Positions | Shorthand | What they carry |
|---|---|---|
| 1 to 3 | WMI | Identifies the manufacturer |
| 4 to 8 | VDS | Describes the vehicle; the attributes encoded depend on the vehicle type |
| 9 | Check digit | A digit 0 to 9 or the letter X, worked out from the other 16 characters |
| 10 | VIS | Model year |
| 11 | VIS | Plant of manufacture |
| 12 to 17 | VIS | Production sequence number for high-volume manufacturers |
VIN positions as set out in 49 CFR 565.15
The same section assigns a value to every letter except I, O and Q, and the model year and plant characters come from the same character set 1. The decoder follows that: it rejects any VIN that is not exactly 17 characters or that contains I, O or Q, before it looks anything up.
The check digit, worked through
The regulation turns the check digit into arithmetic: each character gets a value (digits keep their face value, letters map through a table), each position has a weight, the products are added and the sum is divided by 11 1. The remainder gives the ninth character, and a remainder of 10 is written as X 1.
Here is the code the decoder runs, in JavaScript. Position 9 has weight 0 because it is the character being checked.
const TRANSLITERATION = {
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];
export function checkDigit(vin) {
let sum = 0;
for (let i = 0; i < 17; i++) {
const c = vin[i];
sum += (c >= '0' && c <= '9' ? Number(c) : TRANSLITERATION[c]) * WEIGHTS[i];
}
const r = sum % 11;
return r === 10 ? 'X' : String(r);
}
console.log(checkDigit('1HGCM82633A004352')); // 3, the ninth character
Check digit calculation
For the VIN 1HGCM82633A004352, the products by position are 8, 56, 42, 15, 16, 24, 4, 60, 0, 27, 8, 0, 0, 20, 12, 15 and 4. They add up to 311. Dividing 311 by 11 leaves a remainder of 3, and the ninth character of the VIN is 3, so the check passes. Change any one character and the sum moves, so most single typing mistakes fail the check.
Why a valid VIN can fail the check
The check digit rule belongs to the US regulation 1, and the vehicle data behind the decoder covers vehicles intended for sale or importation into the United States 3. A VIN on a vehicle built for another market may not follow the US rule. For that reason the VIN decoder verifies the check digit by default and refuses a mismatch with a message that tells you the digit it expected. If you know the VIN is real and was built outside North America, send ignore_check_digit: true and the length and character rules still apply.
The data source also says that vehicles not intended for sale or importation into the US return a limited result 3. Expect more empty fields for those.
Decode a VIN with curl
The decoder checks the VIN locally first, then asks the NHTSA vPIC service, which is the US government vehicle decoder 2. VINs that fail the local checks are refused with HTTP 400 and cost nothing, so a typo never reaches NHTSA. Try one VIN with curl before you write code.
curl -X POST https://YOUR_API_HOST/v1/vin-decoder \
-H "Authorization: Bearer $LOOKVALID_API_KEY" \
-H "Content-Type: application/json" \
-d '{"vin":"1HGCM82633A004352"}'
Decode one VIN
The response for that VIN has found: true and a data object. These are the fields, taken from the live capture for that VIN:
| Field | Value for the sample VIN | Notes |
|---|---|---|
| model_year, make, model, trim | 2003, HONDA, Accord, EX-V6 | Top-level strings, or null when NHTSA has no value |
| body_class, doors, transmission | Coupe, 2, Automatic | drive_type was null for this VIN |
| engine | cylinders 6, displacement_l 3, horsepower 240 | Also model, configuration, kw, turbo and manufacturer |
| plant | MARYSVILLE, OHIO, UNITED STATES (USA) | city, state, country and company |
| manufacturer, gvwr | AMERICAN HONDA MOTOR CO., INC.; Class 1C | GVWR comes back as a text class with pound and kilogram ranges |
| safety | airbags, seat_belts, driver_assistance | Mostly null for a 2003 vehicle |
| decode_quality | complete | complete or partial |
| warnings, additional_notes, suggested_vin | empty list, null, null | Filled when NHTSA reports a problem with the VIN |
Fields in data
A null value means NHTSA has no data for that field. It does not mean the vehicle lacks the feature, and the response says so in its notes field. A decode_quality of partial means NHTSA could not resolve every part of the VIN, so check warnings before you trust the model and trim.
Call it from code
This function returns a small summary and turns every refusal into an error that carries the API's message. It uses the built-in fetch, so it has no dependencies.
const API = 'https://YOUR_API_HOST';
export async function decodeVin(vin) {
const response = await fetch(`${API}/v1/vin-decoder`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LOOKVALID_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ vin }),
});
const body = await response.json();
if (!response.ok) {
// A bad length, a bad character or a wrong check digit is a 400 with a message that names the problem.
throw new Error(body.error?.message ?? `HTTP ${response.status}`);
}
if (!body.found) return { vin, decoded: false, reason: body.reason };
const { model_year, make, model, trim, decode_quality } = body.data;
return { vin, decoded: true, model_year, make, model, trim, decode_quality };
}
console.log(await decodeVin('1HGCM82633A004352'));
decode-vin.js
- Input:
vinis required.model_year(1981 to 2040) is optional, and NHTSA recommends always sending it when you have it 2.ignore_check_digitis optional. - Cost: a VIN that decodes costs 1 credit. A refused VIN and a VIN with no data are free.
- Repeats: the same VIN, model year and check setting is served from your account's cache for 7 days at no charge.
- Spelling: the VIN is trimmed and upper-cased before it is checked, so lower-case input from a form is fine.
To try a VIN without writing code, use the free VIN decoder. To see how keys work, start at the documentation.
Use the result in a form
- Ask for the VIN and, if you have it, the model year.
- Call the decoder on submit. On a 400, show the message beside the field, because it names the exact problem.
- On
found: false, keep the VIN and let the person continue, since a real VIN can be missing from the source. - On a match, prefill make, model and year, and let the person correct them.
Treat the decoded fields as a prefill, not a ruling. They come from what the manufacturer reported to NHTSA, and plenty of fields on older vehicles will be empty.
Sources
- US eCFR: 49 CFR 565.15, VIN content requirements, read 2026-10-19
- NHTSA vPIC: Vehicle API documentation, read 2026-10-19
- NHTSA vPIC: Product Information Catalog and Vehicle Listing, read 2026-10-19