VIN Validation in Node.js — Check Digit, WMI, and Model Year
Every car has a 17-character VIN that encodes manufacturer, vehicle type, and model year — plus a check digit calculated with a transliteration table and positional weights that most developers have never seen before.
In this guide
1. What is a VIN?
A Vehicle Identification Number (VIN) is a 17-character alphanumeric code that uniquely identifies a motor vehicle. Standardised by ISO 3779 and mandated by NHTSA in the US since 1981, every new car, truck, and motorcycle sold globally carries a VIN stamped on a plate visible through the windshield and encoded in official documents.
VINs are used by auto dealerships, insurance companies, DMVs, and marketplaces to track vehicle history, verify odometer readings, check for recalls, and confirm ownership. Validating a VIN is the first step before any of these lookups.
2. VIN anatomy — WMI, VDS, VIS
A VIN is divided into three sections, each carrying structured information:
WMI — World Manufacturer Identifier (positions 1–3)
Assigned by NHTSA/SAE. The first character indicates the country of manufacture (1–5 = USA, J = Japan, W = Germany, S = UK/Sweden/Spain, etc.). The three characters together identify the specific manufacturer — e.g. 1G1 is Chevrolet passenger cars made in the USA.
VDS — Vehicle Descriptor Section (positions 4–9)
Six characters defined by the manufacturer to describe the vehicle type, model, body style, engine type, and restraint system. Position 9 is always the check digit — calculated from all other 16 positions.
VIS — Vehicle Identifier Section (positions 10–17)
Eight characters that make each vehicle unique within a manufacturer. Position 10 encodes the model year using a letter/digit code. Position 11 identifies the plant where the vehicle was assembled. Positions 12–17 are the sequential production number.
3. The check digit algorithm — weights and transliteration
The VIN check digit algorithm (NHTSA, 49 CFR 565) is more complex than Luhn. It involves converting letters to numbers using a transliteration table, then multiplying each position by a positional weight, summing, and taking the result mod 11.
Step 1 — Transliterate letters to numbers
| A | B | C | D | E | F | G | H | J | K | L | M | N | P | R | S | T | U | V | W | X | Y | Z |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | 2 | 3 | 4 | 5 | 7 | 9 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
Letters I, O, Q are not allowed in VINs — they are too easily confused with 1, 0, and 0.
Step 2 — Multiply each position by its weight
| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Weight | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 10 | — | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 |
Position 9 (check digit) has weight 0 — it is excluded from the sum.
Step 3 — Sum and take mod 11
Sum all (transliterated value × weight) for positions 1–8 and 10–17. Divide by 11 and take the remainder.
- • Remainder 0–9 → check digit is that digit
- • Remainder 10 → check digit is the letter X
// vinCheckDigit.js — calculate VIN check digit 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, '0':0,'1':1,'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9, }; const WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; function calculateCheckDigit(vin) { const sum = vin.split('').reduce((acc, char, i) => { return acc + (TRANSLITERATION[char] ?? 0) * WEIGHTS[i]; }, 0); const remainder = sum % 11; return remainder === 10 ? 'X' : String(remainder); } const vin = '1G1JC5213TB146027'; // Note: South American VINs skip this check console.log(calculateCheckDigit(vin)); // → should match vin[8] = '3'
4. Model year encoding — the 30-year cycle
Position 10 of the VIN encodes the model year using a 30-year repeating cycle of letters and digits. Letters I, O, Q, U, and Z are excluded (along with 0) to avoid ambiguity.
| Code | First cycle | Second cycle |
|---|---|---|
| A | 1980 | 2010 |
| B | 1981 | 2011 |
| C | 1982 | 2012 |
| D | 1983 | 2013 |
| E | 1984 | 2014 |
| F | 1985 | 2015 |
| G | 1986 | 2016 |
| H | 1987 | 2017 |
| J | 1988 | 2018 |
| K | 1989 | 2019 |
| … | … | … |
| 9 | 2009 | 2039 |
5. Why VIN validation is tricky
Check digit is not universal
The NHTSA check digit algorithm applies only to North American VINs(WMI prefix 1–5). European and most Asian manufacturers do not use this check digit — the 9th position in their VINs can be any valid VIN character. Applying the check digit test to a BMW or Toyota VIN will produce false negatives.
Excluded characters
The letters I, O, and Q are never used in VINs because they are too easily confused with 1, 0, and 0. A VIN containing any of these characters is always invalid — regardless of the check digit.
Pre-1981 vehicles
Vehicles manufactured before 1981 may have VINs shorter than 17 characters with manufacturer-specific formats. The 17-character standardised format was mandated in the US from the 1981 model year onwards.
6. The production-ready solution
The IsValid VIN API validates the 17-character format, applies the check digit algorithm where applicable, and decodes the WMI into manufacturer, country, and region. The response also includes the model year (or year range) from position 10.
Full parameter reference and response schema: VIN Validation API docs →
7. Node.js code example
// vinValidator.js const API_KEY = process.env.ISVALID_API_KEY; const BASE_URL = 'https://api.isvalid.dev'; /** * Validate a VIN using the IsValid API. * * @param {string} vin - Vehicle Identification Number (17 characters) * @returns {Promise<object>} Validation result with decoded fields */ async function validateVin(vin) { const params = new URLSearchParams({ value: vin }); const response = await fetch(`${BASE_URL}/v0/vin?${params}`, { headers: { Authorization: `Bearer ${API_KEY}` }, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(`VIN API error ${response.status}: ${error.message ?? 'unknown'}`); } return response.json(); } // ── Example usage ──────────────────────────────────────────────────────────── const result = await validateVin('1G1JC5213TB146027'); if (!result.valid) { console.log('Invalid VIN'); } else { console.log('Manufacturer:', result.manufacturer); // → 'Chevrolet' console.log('Country:', result.country); // → 'United States' console.log('Region:', result.region); // → 'North America' console.log('Model year:', result.modelYear); // → [1996] or [1996, 2026] console.log('Check digit valid:', result.checkDigit.valid); }
In an automotive marketplace or insurance form:
app.post('/vehicles', async (req, res) => { const { vin, ...vehicleData } = req.body; const check = await validateVin(vin); if (!check.valid) { return res.status(400).json({ error: 'Invalid VIN' }); } // Warn if check digit does not apply (non-North American VIN) const checkDigitNote = check.checkDigit.applicable ? (check.checkDigit.valid ? 'verified' : 'mismatch — possible transcription error') : 'not applicable (non-North American VIN)'; const vehicle = await db.vehicles.create({ vin: check.normalized, manufacturer: check.manufacturer, country: check.country, modelYear: check.modelYear?.[0], checkDigitStatus: checkDigitNote, ...vehicleData, }); res.json({ vehicleId: vehicle.id, manufacturer: check.manufacturer }); });
result.normalized (uppercased, separators stripped) rather than the raw input. Expose the formatted VIN in groups of three using wmi + vds + vis for display purposes.8. cURL example
North American VIN (check digit applies):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vin?value=1G1JC5213TB146027"
European VIN (check digit not applicable):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vin?value=WVWZZZ3CZDE014765"
Invalid VIN (contains forbidden letter O):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vin?value=1G1JC5O13TB146027"
9. Understanding the response
Valid North American VIN:
{ "valid": true, "normalized": "1G1JC5213TB146027", "wmi": "1G1", "vds": "JC5213", "vis": "3B146027", "region": "North America", "country": "United States", "manufacturer": "Chevrolet", "modelYear": [1996], "checkDigit": { "value": "3", "calculated": "3", "valid": true, "applicable": true } }
Valid European VIN (check digit not applicable):
{ "valid": true, "normalized": "WVWZZZ3CZDE014765", "wmi": "WVW", "vds": "ZZZ3CZ", "vis": "DE014765", "region": "Europe", "country": "Germany", "manufacturer": "Volkswagen", "modelYear": [1983, 2013], "checkDigit": { "value": "Z", "calculated": "5", "valid": false, "applicable": false } }
| Field | Type | Description |
|---|---|---|
| normalized | string | Uppercase VIN with separators stripped |
| wmi | string | Positions 1–3: World Manufacturer Identifier |
| vds | string | Positions 4–9: Vehicle Descriptor Section |
| vis | string | Positions 10–17: Vehicle Identifier Section |
| region | string | null | Geographic region from the first WMI character |
| country | string | null | Country of manufacture from the WMI |
| manufacturer | string | null | Manufacturer name if the WMI is in the known list |
| modelYear | number[] | null | 1 or 2 possible model years from position 10 |
| checkDigit.value | string | The check digit character at position 9 |
| checkDigit.calculated | string | The expected check digit per NHTSA algorithm |
| checkDigit.valid | boolean | Whether value === calculated |
| checkDigit.applicable | boolean | false for non-North American VINs — do not use checkDigit.valid if false |
10. Edge cases
Distinguishing check digit mismatch from invalid VIN
For North American VINs, a check digit mismatch is a strong signal of a transcription error — but the API still returns valid: true because the format itself is correct. Use checkDigit.applicable && !checkDigit.valid to surface a specific warning to the user.
if (result.valid) { if (result.checkDigit.applicable && !result.checkDigit.valid) { // Warn: format is OK, but check digit does not match — possible typo showWarning('VIN check digit mismatch. Please double-check the number.'); } }
Two possible model years
For non-North American VINs, modelYear may contain two elements — e.g. [1983, 2013]. Display both and let the user confirm, or cross-reference with the registration document.
Unknown manufacturer
manufacturer is null when the WMI is not in the known dataset. This is not an error — it just means the manufacturer is not in the lookup table (low-volume manufacturers use WMIs ending in 9 and share them across small brands). The VIN is still structurally valid.
Summary
See also
Validate VINs instantly
Free tier includes 100 API calls per day. No credit card required. Returns manufacturer, country, region, model year, and check digit status.