DEV Community

Martin Petrov
Martin Petrov

Posted on

Check a list of VINs for new NHTSA recalls every day with Python

If you manage more than a handful of cars, finding out which of them have an open safety recall usually means typing VINs into the NHTSA website one at a time.

That works for your own car. It does not work for a 40-van delivery fleet, a used-car lot, a rental desk or an insurance workflow where new vehicles arrive every week. And a recall is not a one-time fact: NHTSA publishes new campaigns all the time, so a car that was clean last month can have a new recall today.

I built a small API for this, and in this post I show how to use it from Python: decode a list of VINs, attach the open NHTSA recalls, and then run it every day so you only get the recalls that are new since yesterday. All output below is real, from runs I made today (2026-11-12).

Why the usual approach hurts

NHTSA has two free, official APIs: vPIC (VIN decoding) and the Recalls API. They are good, but using them directly for a fleet means:

  • vPIC returns about 140 fields per VIN, most of them empty strings, and numbers come back as text.
  • The Recalls API does not take a VIN. It takes make, model and model year, so you first decode every VIN, then normalize the make and model, then call recalls per vehicle.
  • If NHTSA is slow or down, a naive script sees an empty answer and reports "0 recalls". For a safety check that is the worst possible failure.
  • To get only new recalls you need to store what you saw last time and diff it.

None of this is hard, but it is a day of glue code plus maintenance. The API below does it in one call.

What the API does

  • Up to 50 VINs per call (and up to 20 vehicles by text like 2021 toyota camry when you have no VIN).
  • Clean decode: make, model, year, trim, body, engine, drive type, transmission, plant, GVWR and safety equipment (ABS, ESC, TPMS, airbags, collision warning) where NHTSA has data.
  • A local check digit test (checkDigitValid), so typos are caught before any network call.
  • recallCount and a recalls list per vehicle: campaign number, component, summary, consequence, remedy, report date, park-it flags and the NHTSA link.
  • If NHTSA does not answer after 3 retries, the row says unavailable. It never says "no recalls" when it does not know.
  • onlyNew + monitorId: returns one row per recall that this monitor has not seen before.

It runs on Apify as Vehicle Recalls & VIN Decoder API. You call it with your own Apify token.

Step 1: get a token

Create an Apify account (the free plan includes a small monthly usage credit), open Settings -> API & Integrations and copy your personal API token. Put it in an environment variable:

export APIFY_TOKEN="your_token_here"
Enter fullscreen mode Exit fullscreen mode

Step 2: one call with curl

curl -s -X POST "https://api.apify.com/v2/acts/martin10291~vehicle-recalls-vin-decoder/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"vins": ["1HGCV1F35KA000001", "1FTFW1ET9DFC10312"], "vehicles": ["2021 toyota camry"], "includeRecalls": true}'
Enter fullscreen mode Exit fullscreen mode

run-sync-get-dataset-items starts the run, waits for it and returns the rows. My run took 6.3 seconds for these 3 vehicles.

Step 3: the same in Python

import os
import requests

URL = ("https://api.apify.com/v2/acts/martin10291~vehicle-recalls-vin-decoder"
       "/run-sync-get-dataset-items")

def check_fleet(vins, vehicles=None):
    payload = {"vins": vins, "vehicles": vehicles or [], "includeRecalls": True}
    r = requests.post(URL, params={"token": os.environ["APIFY_TOKEN"]},
                      json=payload, timeout=300)
    r.raise_for_status()
    return r.json()

rows = check_fleet(["1HGCV1F35KA000001", "1FTFW1ET9DFC10312"], ["2021 toyota camry"])
for row in rows:
    name = f'{row["modelYear"]} {row["make"]} {row["model"]}'
    print(row["status"], row["vin"], name, "recalls:", row["recallCount"])
    for rc in row.get("recalls", []):
        print("   ", rc["campaignNumber"], rc["reportDate"], rc["component"])
Enter fullscreen mode Exit fullscreen mode

Real output from today:

ok 1HGCV1F35KA000001 2019 HONDA Accord recalls: 6
    26V332000 2026-05-22 AIR BAGS:SENSOR:OCCUPANT CLASSIFICATION:FRONT PASSENGER
    23V858000 2023-12-19 FUEL SYSTEM, GASOLINE:DELIVERY:FUEL PUMP
    23V158000 2023-03-09 SEAT BELTS:FRONT:BUCKLE ASSEMBLY
    21V215000 2021-03-25 FUEL SYSTEM, GASOLINE:DELIVERY:FUEL PUMP
    20V771000 2020-12-10 ELECTRICAL SYSTEM:BODY CONTROL MODULE:SOFTWARE
    20V314000 2020-05-28 FUEL SYSTEM, GASOLINE:DELIVERY:FUEL PUMP
ok 1FTFW1ET9DFC10312 2013 FORD F-150 recalls: 3
    19V433000 2019-06-10 POWER TRAIN:AUTOMATIC TRANSMISSION
    19V075000 2019-02-11 POWER TRAIN:AUTOMATIC TRANSMISSION:CONTROL MODULE (TCM/PCM/TECM)
    16V345000 2016-05-24 SERVICE BRAKES, HYDRAULIC:FLUID
ok None 2021 toyota camry recalls: 1
    23V865000 2023-12-20 AIR BAGS:SENSOR:OCCUPANT CLASSIFICATION
Enter fullscreen mode Exit fullscreen mode

And one decoded row, shortened (the recalls list is cut to one entry):

{
  "type": "vehicle",
  "vin": "1FTFW1ET9DFC10312",
  "status": "ok",
  "checkDigitValid": true,
  "make": "FORD",
  "model": "F-150",
  "modelYear": 2013,
  "bodyClass": "Pickup",
  "vehicleType": "TRUCK",
  "engine": {"cylinders": 6, "displacementL": 3.5, "hp": 365, "fuelType": "Gasoline", "configuration": "V-Shaped"},
  "driveType": "4WD/4-Wheel Drive/4x4",
  "plantCity": "DEARBORN",
  "plantState": "MICHIGAN",
  "gvwr": "Class 2F: 7,001 - 8,000 lb (3,175 - 3,629 kg)",
  "safety": {"tpms": "Direct", "airbagsFront": "1st Row (Driver and Passenger)", "airbagsSide": "1st and 2nd Rows"},
  "recallCount": 3,
  "recalls": [{
    "campaignNumber": "19V433000",
    "component": "POWER TRAIN:AUTOMATIC TRANSMISSION",
    "consequence": "Unexpectedly downshifting into first gear may result in a loss of vehicle control, increasing the risk of a crash."
  }],
  "source": "NHTSA vPIC + NHTSA Recalls API (US market vehicles)",
  "checkedAt": "2026-11-12T14:31:42Z"
}
Enter fullscreen mode Exit fullscreen mode

Note the Toyota row: it came from the text 2021 toyota camry, so there is no VIN and no decoded specs, only the recalls for that make, model and year.

Step 4: only the new recalls, every day

For a fleet you do not want the full list every morning. You want "what changed". Turn on onlyNew and give your list a monitorId:

def new_recalls(vins, monitor_id="my-fleet"):
    payload = {"vins": vins, "onlyNew": True, "monitorId": monitor_id}
    r = requests.post(URL, params={"token": os.environ["APIFY_TOKEN"]},
                      json=payload, timeout=300)
    r.raise_for_status()
    return r.json()

rows = new_recalls(["1HGCV1F35KA000001", "1FTFW1ET9DFC10312"])
for row in rows:
    print(row["vin"], row["campaignNumber"], row["component"])
print(len(rows), "new recalls")
Enter fullscreen mode Exit fullscreen mode

In monitor mode you get one row per recall (with the vehicle fields included), not one row per car. Here is what happened today with the same two VINs:

Run Rows Time
1st run (new monitor) 9 (all current recalls: 6 Honda + 3 Ford) 4.2 s
2nd run, a minute later 0 3.4 s

The first run returns everything because the monitor has never seen these cars. From the second run on, you only get campaigns that NHTSA added since the last run. Each monitorId keeps its own memory, so you can run one per customer or per depot.

To run it daily, use either:

  • cron on your own machine: 0 7 * * * APIFY_TOKEN=... /usr/bin/python3 /home/you/fleet_recalls.py, and make the script send an e-mail or a Slack message only when len(rows) > 0; or
  • an Apify schedule: open the actor, save your input (with onlyNew: true and your monitorId) as a task, and add a daily schedule. You can attach a webhook to the task so the new rows go to your own endpoint.

Use it from an AI agent

The actor also works through the Apify MCP server. Add https://mcp.apify.com?tools=martin10291/vehicle-recalls-vin-decoder to Claude, Cursor or another MCP client and ask "Decode these VINs and list their open recalls".

Honest limits

  • Recalls are matched by make, model and model year, not by the repair status of one VIN. A listed recall means that model year is covered by the campaign. It does not tell you whether this particular car was already fixed. For that, the owner or a dealer has to check the individual VIN on NHTSA's own lookup page or with the manufacturer.
  • US market data. vPIC is built for vehicles sold in the US and Canada. Many European or Asian market VINs decode only partly or not at all (not_decoded).
  • Empty fields mean NHTSA has no data, not that the car lacks the feature.
  • A make with several words (for example Land Rover) needs the object form in vehicles: {"year": 2020, "make": "land rover", "model": "defender"}.
  • A make or model that NHTSA does not recognize returns 0 recalls, the same as a model with no recalls. Check status and the decoded make before you trust a 0.

Price

$1 per 1,000 result rows: one row per vehicle in normal mode, one row per new recall in monitor mode. A daily monitor run that finds nothing new returns 0 rows. The data itself is free and official; you pay for the decoding, the cleanup, the retries and the memory of what you already saw.

If you try it on your own list and something decodes badly, open an issue on the actor page with the VIN pattern and I will look at it. What would make this more useful for your fleet: a CSV upload, or a direct e-mail when a new recall appears?

Top comments (0)