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 camrywhen 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. -
recallCountand arecallslist 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" 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}' 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"]) 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 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" } 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") 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 whenlen(rows) > 0; or - an Apify schedule: open the actor, save your input (with
onlyNew: trueand yourmonitorId) 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
statusand the decodedmakebefore 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?
