Most used cars in Ghana come from the United States. They arrive through auctions, get shipped to Tema, and end up on a roadside lot or an online listing with a seller who says "accident free, low mileage, one owner."

In August 2026 the Ghana Standards Authority announced that used vehicles 15 years and older would be banned from import from 1 October 2026. Buyers suddenly needed to know a car's real model year, not just what the seller typed into the ad.

I'm co-founder of TrustCheck, a platform that helps people in Ghana check a business, phone number or price before they pay. We already had a price checker, and we wanted a second free tool: paste a VIN and get the car's real details, its recalls, its owner complaints and its crash ratings.

Everything in it comes from free public data with no API keys:

  • NHTSA vPIC to decode the VIN
  • NHTSA recalls, owner complaints and 5-star crash ratings
  • US EPA fueleconomy.gov, combined with Ghana's current fuel price, to estimate fuel cost per month in cedis

The APIs are good. The surprises were in the gaps between them. Here's what I ran into, in code.

1. Clean the VIN before you trust it

People type VINs from photos of windscreens. They confuse 0 with O and 1 with I. A VIN never contains I, O or Q (the standard leaves them out for exactly this reason), so for a 17-character entry you can safely map them back:

public static function cleanVin(?string $vin): ?string { $vin = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string) $vin)); if (strlen($vin) === 17) { $vin = strtr($vin, ['I' => '1', 'O' => '0', 'Q' => '0']); } return preg_match('/^[A-HJ-NPR-Z0-9]{17}$/', $vin) ? $vin : null; } 

This one change turned a lot of "invalid VIN" errors into real results.

2. The check digit only means something for some cars

Character 9 of a VIN is a check digit, computed from the other 16 with a weighted sum mod 11:

public static function checkDigit(string $vin): array { $map = array_combine( str_split('ABCDEFGHJKLMNPRSTUVWXYZ'), [1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 7, 9, 2, 3, 4, 5, 6, 7, 8, 9] ); $weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; $sum = 0; foreach (str_split($vin) as $i => $ch) { $sum += (ctype_digit($ch) ? (int) $ch : $map[$ch]) * $weights[$i]; } $expected = $sum % 11 === 10 ? 'X' : (string) ($sum % 11); return [ 'ok' => $vin[8] === $expected, 'north_america' => in_array($vin[0], ['1', '2', '3', '4', '5'], true), ]; } 

My first version shouted "this VIN may be fake" whenever the digit didn't match. That was wrong. The check digit is required on cars made for the North American market (VINs starting 1 to 5). Many cars built for Japan, Europe or the Gulf don't use it at all, and Ghana gets plenty of those.

So the warning is strong only when the VIN is North American. For everything else, a mismatch is just a note. A tool that cries wolf on half the cars people check gets ignored, so a false alarm is a bug too.

3. NHTSA doesn't agree with itself on model names

This was the biggest surprise. The VIN decoder, the recalls API and the complaints API don't always spell a model the same way:

  • The decoder says model "Sierra", series "1500". Recalls are filed under "SIERRA 1500".
  • Lexus "RX 350" is "RX350" in the recalls database.
  • Recalls want "C300", while complaints want "C-CLASS".

I found this on a 2017 GMC Sierra. My page proudly showed 0 recalls. NHTSA actually has 6 recalls and 433 complaints for it. A tool that tells someone "no recalls" when there are six is worse than having no tool.

The fix is a chain of fallbacks that only runs when the name as given finds nothing:

$resolved = $noRecalls() ? $this->resolveModel($make, $model, $year) : $model; foreach (array_unique(array_filter([ $this->withSeries($model, $vinFacts), // "Sierra" + "1500" $resolved !== $model ? $resolved : null, // "RX 350" -> "RX350" ...$this->altNames($model, $alias, $vinFacts), // "C300" from the trim ])) as $alt) { if ($noRecalls()) { $retry = $this->get(NHTSA . '/recalls/recallsByVehicle', [ 'make' => $make, 'model' => $alt, 'modelYear' => $year, ]); if (!empty($retry['results'])) { $r['recalls'] = $retry; $nhtsaModel = $alt; // reuse this name for complaints too } } } 

resolveModel() downloads NHTSA's own list of model names for that make and year (cached for 30 days), then matches on a normalised form with spaces and punctuation stripped. Whichever name finds the recalls is saved and reused for the complaints call, so the two sections of the report always describe the same car.

Lesson: when you combine endpoints from the same provider, don't assume they share identifiers. Test with real, messy inputs, especially trucks and luxury trims.

4. One endpoint takes 9 seconds

The complaints endpoint can take around 9 seconds on a popular model. Called one after another, the whole report would take 15+ seconds, and on a phone over mobile data that feels broken.

I did three things.

Parallel requests. Recalls, crash ratings and the EPA model list don't depend on each other, so they go out together with Laravel's Http::pool (25-second timeout). A second parallel round fetches what depends on the first: the crash-rating details and the EPA engine options.

$responses = Http::pool(fn (Pool $pool) => [ $pool->as('recalls')->timeout(25)->get($recallsUrl, $recallsQuery), $pool->as('ratings')->timeout(25)->get($ratingsUrl), $pool->as('epa')->timeout(25)->get($epaUrl, $epaQuery), ]); 

Different cache times for complete and partial answers. A complete answer is cached for 7 days. If any source failed, the partial answer is cached for only 15 minutes, so a short NHTSA hiccup doesn't get stuck for a week.

Warming at night. We have 312 model pages (popular models × 2010 to 2022). A scheduled job loads 24 model-years an hour between 1 and 5:30 am, keeping its place in the cache, so by morning the common pages are already fast.

On the front end, pressing Check swaps in a skeleton of the report with honest step messages ("Checking recalls…") instead of a bare spinner. People wait longer when they can see what's happening.

5. Building our own VIN history without storing personal data

The US data can't catch the scam that matters most in Ghana: the same car advertised again with lower mileage, or the same photos used by several sellers.

So every check records a "sighting": the VIN, the date, and what the seller claimed (mileage, year, price). The next person who checks that VIN sees the earlier checks, plus warnings like:

  • the mileage went down by more than 1,000 km between checks (a likely odometer rollback)
  • the same VIN is being sold from several different phone numbers (likely stolen photos)
  • the claimed model year keeps changing

The tricky part is that this needs the seller's number to count distinct sellers, and I didn't want a database of phone numbers. So the number and the checker's IP are stored only as HMAC hashes:

public static function hash(string $value): string { return hash_hmac('sha256', $value, 'car-vin|' . config('app.key')); } 

With a hash, we can count and compare ("3 different sellers") but can't read the number back. A keyed HMAC rather than a plain SHA-256 matters here: phone numbers are a small space, so an unkeyed hash could be reversed by brute force in minutes.

The rollback check itself is simple. Walk the sightings in date order and track the highest mileage seen so far:

foreach ($miles as $r) { if ($peak && $r->mileage_km + 1000 < $peak->mileage_km) { $rollback = ['from' => $peak->mileage_km, 'to' => $r->mileage_km]; } if (!$peak || $r->mileage_km > $peak->mileage_km) { $peak = $r; } } 

There's one row per visitor per VIN per day, so someone refreshing the page can't fake a history.

6. Not every car has a VIN

Cars built for the Japanese market often have a chassis number instead, like NZE141-1234567: a model code, a dash, then a serial number. NHTSA can't decode those, and neither can it fully decode many Gulf and European VINs.

Rather than show an error, the tool accepts the chassis format and runs the report on the seller's stated make, model and year. It says clearly which parts of the report came from the seller rather than the VIN. A partial answer that's honest about where it came from still helps someone standing at a car lot.

What I'd tell another developer

  1. Public government APIs are underrated. Recalls, complaints, crash ratings and fuel economy for free, with no key.
  2. Your bugs will be in the joins between systems, not inside any one of them. Test with real inputs from your users' market.
  3. A false "all clear" is the worst bug in a safety tool. I'd rather show "we couldn't check this" than "0 recalls".
  4. Cache partial results for a short time and complete ones for a long time.
  5. You can build useful shared history without collecting personal data. Keyed hashes let you count without knowing.

The tool is live and free at trustcheckapp.com/tools/car-check. If you've worked with NHTSA's APIs and hit other naming quirks, I'd love to hear about them in the comments.

I'm Elliot, co-founder of TrustCheck, an enterprise technical writer and a researcher. I write about what I learn building software for the Ghanaian market.