Transport Canada recall lookup by VIN, by API

Per-VIN recall checks with citable authority — Transport Canada + NHTSA fused — and entity-scoped sweeps for fleets.

bash
curl "https://api.cardog.app/v2/recalls/vin/2T3R1RFV7MW180266" \
  -H "x-api-key: $CARDOG_API_KEY"

Example

7 keys
"response": {
"vin": "2T3R1RFV7MW180266",
"modelYearRef": "model-year:toyota/rav4/2021",
"resolved": true,
"total": 2,
"recalls": [
0: {9 items}
],
"asOf": "2026-07-23T10:04:24.442Z",
"links": {
"identity": "/v2/vin/2T3R1RFV7MW180266"
}
}

A VIN in, the campaigns affecting that vehicle out — Transport Canada and NHTSA fused into one answer, each campaign carrying its issuing authority and official campaign number so the result is citable, not just displayable. 5 credits per VIN checked.

The VIN is bridged to its model-year node in the graph, and the campaigns keyed to that node come back — Decode a Canadian VIN by API covers the identity the check bridges through.

What SOR/2024-274 requires

SOR/2024-274 amended Canada's Motor Vehicle Safety Regulations: s.15.05 obliges vehicle manufacturers to operate a public, VIN-searchable recall lookup, so that anyone holding a VIN can learn whether that vehicle has outstanding recalls. If you build for dealers, insurers, lenders, fleets, or a registry, that per-VIN check is the one your surface has to get right — and getting it right means getting the negative answer right, which is the part most integrations break on.

resolved: false is not "no recalls"

The check has two distinct empty states, and conflating them is the classic compliance bug:

Example

7 keys
"response": {
"vin": "1HGCM82633A123456",
"modelYearRef": null,
"resolved": false,
"total": 0,
"recalls": [
],
"asOf": "2026-07-23T10:04:24.442Z",
"links": {
"identity": "/v2/vin/1HGCM82633A123456"
}
}
  • resolved: true, total: 0 — the VIN is bridged to the graph and the corpus holds no campaigns for it. That is a clean answer you can act on.
  • resolved: false — the VIN is not bridged yet. total: 0 here is vacuous: the platform is telling you it cannot support a recall claim for this VIN, not that the vehicle is clear.

Branch on resolved before you branch on total. A compliance surface that renders resolved: false as "No recalls ✓" is wrong in exactly the way s.15.05 exists to prevent — this is the same null-semantics law that runs through the whole API: absence of evidence is stated, never dressed up as evidence of absence.

Both authorities, kept distinct

total: 2 on the RAV4 above, second record elided: the same occupant classification defect exists as both Transport Canada campaign 2023684 and NHTSA campaign 23V865000. The corpus fuses the authorities into one answer but keeps their records distinct — each stays citable against its own issuer.

Citing a result

Every campaign is attributable, because an uncited recall answer is worth nothing in a dispute:

  • authority / authorityLabel — who issued the campaign ("tc" / Transport Canada, "nhtsa" / NHTSA — the set is open).
  • campaignNumber — the authority's own identifier, resolvable in their systems.
  • asOf — corpus freshness at the moment of the check. Persist it with the check: "no recalls as of {asOf}, per {authority}" is a defensible record; "no recalls" is not.

Sweeping at entity scope

Fleets and portfolios do not check VIN-by-VIN first — they watch the entities they hold. Any make, model, or model-year ref sweeps its campaigns:

bash
# Everything affecting a model year you hold in volume
curl "https://api.cardog.app/v2/recalls/entity/model-year:toyota%2Frav4%2F2021" \
  -H "x-api-key: $CARDOG_API_KEY"

# The whole make, for portfolio triage
curl "https://api.cardog.app/v2/recalls/entity/make:toyota" \
  -H "x-api-key: $CARDOG_API_KEY"

The pattern that scales: sweep the entity refs you hold on a schedule, and when a new campaign lands, run the per-VIN check across the affected slice of your fleet. GET /v2/recalls/feed (newest campaigns first) and GET /v2/recalls/stats (corpus counters and latest recall date) drive the watch loop. The full route surface for the group is in the recalls reference.

The affects grain

A campaign's scope is campaign × model-year: each entry in affects binds one model-year ref with its unitsAffected where the authority published one. Two consequences worth knowing. First, affects is scoped to how you asked — an entity query lists only the years in your scope, the campaign detail (GET /v2/recalls/{recall-ref}) lists them all. Second, the grain is the authority's, not the vehicle's: a campaign names model years, and the per-VIN check is that grain bridged through the VIN's identity. Where a manufacturer scopes below the model year (a VIN range, a plant window), the authoritative per-VIN answer is the manufacturer's own s.15.05 lookup — the campaign record here tells you exactly which one to ask, and cite.

Pricing

A VIN check is 5 credits. Non-VIN registry reads in the group — entity sweeps, the feed, stats, campaign detail — are 1 credit each. See Credits & limits.

What is not here

Recall completion status is not in this API. Whether a specific vehicle has had a specific campaign performed lives with the manufacturer, and we do not redistribute it. If your product needs "was this fixed," you need an OEM relationship, not a data vendor.

If you need one lookup, use Transport Canada's public recall database. It is free and it is the primary source. What we add is VIN-level matching, NHTSA fusion, entity-scoped sweeps, and an API you can put behind a product. If you're doing this once by hand, see How to look up vehicle recalls by VIN.

Push notification is not built. Recall watches — tell me when a campaign lands on a VIN I care about — are on the roadmap, not in the API. Today you poll GET /v2/recalls/feed.