Migration

How to migrate from CarsXE to Cardog

Last updated:

Replace CarsXE's /specs call with two Cardog calls, and use a small adapter you own to keep CarsXE-shaped code working while you compare results.

Most teams start with CarsXE's Specifications endpoint, GET /specs. On Cardog the same data comes from two calls: GET /v2/vin/{vin} for identity and the decoder's attribute map, then GET /v2/specs/nano:{code} for the spec sheet, with every number labelled with its unit and Canadian and US prices kept apart. Most fields have a match, some come back as entity refs that join to the rest of the API, and the table below lists every one.

Cardog wrote this guide. Each claim about CarsXE links to CarsXE's own public documentation, and every page was checked on 2026-10-26. CarsXE can change its docs at any time, so where their pages and this summary differ, go by their pages.

The short version

  1. Get a Cardog API key. Send it in the x-api-key header, not as a key query parameter. Pin Cardog-Version: 2026-10-21.
  2. Replace GET https://api.carsxe.com/specs?key=...&vin=... with GET https://api.cardog.app/v2/vin/{vin}. If valid is true, take nano from that response (it looks like nano:5TDGSKFCRS) and call GET https://api.cardog.app/v2/specs/{nano}.
  3. Add the adapter below to your code. It turns those two responses into the CarsXE shape for the fields that map.
  4. Run your own VINs through both APIs and diff the results before you switch traffic.
  5. Over time, read the Cardog fields directly and use the refs (refs.make, nano, squish) as join keys instead of display strings.

How their fields map to ours

"identity" means the GET /v2/vin/{vin} response, defined in packages/contracts/src/schemas/vin.v2.schemas.ts. identity.attributes is the decoder's attribute map for the build, keyed by the ids in our spec catalogue (GET /v2/specs/catalog). Each entry is an object, usually { value }, and a measured value can carry a unit, so check it before you use a number. The map is sparse: an entry is there only where the decoder has that fact for the build. "sheet" means the GET /v2/specs/nano:{code} response, defined in packages/contracts/src/schemas/specs.v2.schemas.ts. Every sheet value is an object { value, unit?, stated? }. stated holds the number in the unit the source published, when that differs from the unit we serve. Attribute ids and units come from packages/entities/generated/spec.json.

CarsXE's field list is from their Specifications page. CarsXE returns every attribute as a string.

CarsXE fieldCardog fieldNote
successHTTP status + identity.validDifferent. Both APIs return 400 for input that is not a 17-character VIN. CarsXE returns 404 when it finds no data for the VIN. Cardog returns 200 with valid: false and a non-empty reasons array.
inputidentity.vinOnly the VIN is echoed back.
timestampnot mappedidentity.corgiBuild names the decoder build that produced the answer.
attributes.yearidentity.yearSame. Integer, not string.
attributes.makeidentity.makeSame. identity.refs.make is the join key.
attributes.modelidentity.modelSame. identity.refs.model is the join key.
attributes.trimidentity.trimSame: the catalogue trim decoded from the VIN. Optional.
attributes.styleidentity.refs.bodyStyleRenamed. A ref such as body-style:sedan-saloon, not the manufacturer's uppercase label.
attributes.typeidentity.refs.vehicleTypeRenamed. Ref value.
attributes.made_inidentity.refs.countryFormat change: the plant country as a country ref, such as country:canada, not a display name. null when the decoder does not state one.
attributes.made_in_cityidentity.attributes.plantCity, or sheet.sections.build.plantCityRenamed. Where the decoder or the sheet has it.
attributes.enginenot mapped as one stringWe serve the parts: identity.attributes.displacementL, cylinders, engineConfiguration, camType, horsepowerFrom, engineModel, and on the sheet sections.engine.*.
attributes.engine_sizeidentity.attributes.displacementL, or sheet.sections.engine.displacementLA number in litres, not a string.
attributes.engine_cylindersidentity.attributes.cylinders, or sheet.sections.engine.cylindersSame. Integer.
attributes.fuel_typeidentity.refs.fuelTypeRef value. A dual-fuel vehicle also has refs.fuelTypeSecondary.
attributes.fuel_capacitysheet.sections.efficiency.fuelTankCapacityUnit change: L. stated holds gallons when the source published gallons.
attributes.city_mileagesheet.sections.efficiency.fuelEconomyCityUnit change: L100K. stated holds MPG when published.
attributes.highway_mileagesheet.sections.efficiency.fuelEconomyHighwayUnit change: L100K. stated holds MPG when published.
attributes.transmissionidentity.refs.transmission + sheet.sections.drivetrain.transmissionSpeedsSplit into a ref and a count.
attributes.transmission_shortnot mapped
attributes.transmission_typeidentity.refs.transmissionRef value.
attributes.transmission_speedsidentity.attributes.transmissionSpeeds, or sheet.sections.drivetrain.transmissionSpeedsSame. Integer.
attributes.drivetrainidentity.refs.driveTypeRef value such as drive-type:4wd-4-wheel-drive-4x4.
attributes.overall_heightsheet.sections.dimensions.heightUnit change: MM.
attributes.overall_lengthsheet.sections.dimensions.lengthUnit change: MM.
attributes.overall_widthsheet.sections.dimensions.widthUnit change: MM.
attributes.wheelbase_lengthsheet.sections.dimensions.wheelbase, or identity.attributes.wheelbaseUnit change: MM on the sheet. Check the attribute's unit.
attributes.curb_weightsheet.sections.dimensions.curbWeight, identity.attributes.curbWeightUnit change: KG on the sheet, with stated in LB. Check the attribute's unit. identity.shippingWeight is an integer in pounds.
attributes.gross_vehicle_weight_ratingidentity.refs.gvwr + identity.grossWeightThe class is a ref. grossWeight is in pounds.
attributes.doorsidentity.doorsSame. Integer.
attributes.standard_seatingsheet.sections.identity.seatingCapacity, or identity.attributes.seatingCapacityRenamed. Integer.
attributes.invoice_pricenot mapped
attributes.manufacturer_suggested_retail_pricesheet.markets.US.pricing.msrp[], sheet.markets.CA.pricing.msrp[]; also identity.attributes.msrpOn the sheet, every published price as its own row, cheapest first. USD and CAD are kept separate, and we never pick one row. identity.attributes.msrp is the decoder's US base price, where it has one.
equipment.abs_brakessheet.sections.safety.abs, or identity.attributes.absValue change: standard, optional or unavailable on the sheet, instead of Std. or empty.
equipment.driver_airbag, passenger_airbagsheet.sections.safety.frontAirbags; identity.attributes.frontAirbagLocationsOne sheet attribute covers both. The decoder's entry names the locations, where it has them.
equipment.side_airbagsheet.sections.safety.sideAirbags; identity.attributes.sideAirbagLocationsThree states on the sheet.
equipment.traction_controlsheet.sections.safety.tractionControl, or identity.attributes.tractionControlThree states on the sheet.
equipment.stability_controlsheet.sections.safety.stabilityControl, or identity.attributes.stabilityControlThree states on the sheet.
equipment.navigation_aidsheet.sections.infotainment.navigationRenamed. Three states.
equipment.power_sunroofsheet.sections.comfort.sunroofRenamed. Does not say whether it is powered.
equipment.backup_camerasheet.sections.driverAssistance.backupCamera, or identity.attributes.backupCameraThree states on the sheet.
equipment.bluetoothsheet.sections.infotainment.bluetoothSame, three states.
equipment.keyless_entrysheet.sections.comfort.keylessEntrySame, three states.
equipment.remote_startsheet.sections.comfort.remoteStartSame, three states.
equipment.leather_seatnot mapped
equipment.alloy_wheelsnot mapped
other equipment.* flagspartialCheck each one against GET /v2/specs/catalog.
colors[] { category, name }sheet.markets.{US,CA}.colors.colorsExterior[], colorsInterior[]The category is the array. Each colour has rgb and hex, which are null when not published. Lists every colour offered across the trims behind the build.
warranties[] Basicsheet.sections.warranty.warrantyBasicMiles, warrantyBasicMonthsNumbers, not strings like "50,000 mile". Distance in KM, with stated in MI.
warranties[] Powertrainsheet.sections.warranty.warrantyPowertrainMiles, warrantyPowertrainMonthsUnit change, as above.
warranties[] Rustsheet.sections.warranty.warrantyCorrosionMiles, warrantyCorrosionMonthsRenamed to corrosion. Unit change, as above.
deepdataidentity.attributes (closest)Not the same thing. CarsXE's deepdata is raw upstream variables behind an opt-in flag. identity.attributes is always included when the decoder has entries, keyed by our catalogue ids, and it is not a second source.
format=xmlnot mappedJSON only.
disableIntVINDecodingnot mappedNothing to switch off. A VIN we cannot name returns valid: false with reasons.
(none)identity.nano, identity.squish, identity.refs, identity.links, identity.reasonsCardog only: build and configuration keys, entity refs, links to recalls and listings, and a fixed set of failure codes.
(none)identity.attributes.* beyond the rows aboveCardog only, where the decoder has them: for example horsepowerFrom, engineModel, fuelSystem, plantCompany, battery fields for EVs, and ADAS entries such as adaptiveCruiseControl.

A field can be missing from the identity's attributes because the decoder does not have it for that build. A field can be missing from a Cardog sheet for three reasons. No source stated it. The catalogue trims behind the build disagreed, or only some of them stated it. Or the stated value did not fit the catalog's type. Where the decoder states a value for the build, that value is served. We never average catalogue values. CarsXE also documents that every field is optional, so your code should already handle gaps.

An adapter you can paste

This function calls Cardog and returns an object in CarsXE's documented /specs shape. It reads the sheet first and falls back to the identity's attributes. Where we have no matching value, the field is undefined and a comment explains why. It runs in your code. Cardog itself only returns its own v2 shape.

const BASE = "https://api.cardog.app";
const HEADERS = { "x-api-key": process.env.CARDOG_API_KEY!, "Cardog-Version": "2026-10-21" };

type V = { value: unknown; unit?: string; stated?: { value: number; unit: string } };
type A = { value?: unknown; unit?: string };

async function get(path: string) {
  const res = await fetch(`${BASE}${path}`, { headers: HEADERS });
  return { status: res.status, body: res.ok ? await res.json() : null };
}

// The source's own number, only when it was published in the unit CarsXE uses.
const asStated = (v: V | undefined, unit: string) =>
  v?.stated?.unit === unit ? String(v.stated.value) : undefined;
const str = (v: V | undefined) => (v == null ? undefined : String(v.value));
const std = (v: V | undefined) =>
  v == null ? undefined : v.value === "standard" ? "Std." : ""; // "optional" has no CarsXE equivalent

export async function carsxeSpecs(vin: string) {
  const { status, body: id } = await get(`/v2/vin/${encodeURIComponent(vin)}`);
  if (status !== 200 || !id?.valid) {
    // CarsXE answers 400 or 404 here; Cardog explains a miss in id.reasons.
    return { success: false, input: { vin }, error: id?.reasons ?? status };
  }

  const sheet = (await get(`/v2/specs/${encodeURIComponent(id.nano)}`)).body; // 404 leaves sheet null
  const s = sheet?.sections ?? {};
  const a: Record<string, A> = id.attributes ?? {};
  // A decoder attribute with no unit attached, as a string.
  const plain = (key: string) =>
    a[key]?.value != null && !a[key].unit ? String(a[key].value) : undefined;
  const us = sheet?.markets?.US;
  const usd = us?.pricing?.msrp ?? [];

  return {
    success: true,
    input: { vin },
    timestamp: undefined, // not returned by Cardog
    attributes: {
      year: id.year != null ? String(id.year) : undefined,
      make: id.make ?? undefined,
      model: id.model ?? undefined,
      trim: id.trim ?? undefined,
      style: undefined, // Cardog returns a ref: id.refs.bodyStyle
      type: undefined, // ref: id.refs.vehicleType
      made_in: undefined, // ref: id.refs.country, e.g. "country:canada"
      made_in_city: str(s.build?.plantCity) ?? plain("plantCity"),
      engine: undefined, // no combined descriptor; parts are in s.engine and a.*
      engine_size: str(s.engine?.displacementL) ?? plain("displacementL"),
      engine_cylinders: str(s.engine?.cylinders) ?? plain("cylinders"),
      fuel_type: undefined, // ref: id.refs.fuelType
      fuel_capacity: asStated(s.efficiency?.fuelTankCapacity, "GAL"),
      city_mileage: asStated(s.efficiency?.fuelEconomyCity, "MPG"),
      highway_mileage: asStated(s.efficiency?.fuelEconomyHighway, "MPG"),
      transmission: undefined, // ref id.refs.transmission + s.drivetrain.transmissionSpeeds
      transmission_short: undefined, // not mapped
      transmission_type: undefined, // ref: id.refs.transmission
      transmission_speeds: str(s.drivetrain?.transmissionSpeeds) ?? plain("transmissionSpeeds"),
      drivetrain: undefined, // ref: id.refs.driveType
      // Dimensions are held in MM; these fill only when a source published inches.
      overall_height: asStated(s.dimensions?.height, "IN"),
      overall_length: asStated(s.dimensions?.length, "IN"),
      overall_width: asStated(s.dimensions?.width, "IN"),
      wheelbase_length: asStated(s.dimensions?.wheelbase, "IN"),
      curb_weight: asStated(s.dimensions?.curbWeight, "LB"),
      gross_vehicle_weight_rating: undefined, // ref id.refs.gvwr; id.grossWeight is lbs
      doors: id.doors != null ? String(id.doors) : undefined,
      standard_seating: str(s.identity?.seatingCapacity) ?? plain("seatingCapacity"),
      invoice_price: undefined, // not mapped
      // Only when one US price stands; several trims can share a build.
      manufacturer_suggested_retail_price:
        usd.length === 1 ? `$${usd[0].msrp.toLocaleString("en-US")} USD` : undefined,
    },
    equipment: {
      abs_brakes: std(s.safety?.abs),
      driver_airbag: std(s.safety?.frontAirbags),
      passenger_airbag: std(s.safety?.frontAirbags),
      side_airbag: std(s.safety?.sideAirbags),
      traction_control: std(s.safety?.tractionControl),
      stability_control: std(s.safety?.stabilityControl),
      navigation_aid: std(s.infotainment?.navigation),
      power_sunroof: std(s.comfort?.sunroof),
      backup_camera: std(s.driverAssistance?.backupCamera),
      bluetooth: std(s.infotainment?.bluetooth),
      keyless_entry: std(s.comfort?.keylessEntry),
      remote_start: std(s.comfort?.remoteStart),
      leather_seat: undefined, // not mapped
      alloy_wheels: undefined, // not mapped
    },
    colors: [
      ...(us?.colors?.colorsExterior ?? []).map((c: { name: string }) => ({ category: "Exterior", name: c.name })),
      ...(us?.colors?.colorsInterior ?? []).map((c: { name: string }) => ({ category: "Interior", name: c.name })),
    ],
    warranties: undefined, // available in s.warranty as numbers (KM / MONTHS); not reformatted here
  };
}

asStated returns a value only when the source published the number in the unit CarsXE uses. We hold dimensions in millimetres, so the four inch fields will often be undefined. Curb weight, fuel and economy usually carry the US number in stated. If you want every value, convert from our metric value yourself. Either way you know which numbers were published and which you calculated.

Verify before you switch

  1. Pick 20 to 50 VINs from your own traffic. Include old and new model years, a few EVs and hybrids, and any Canadian-market vehicles you see.
  2. Call CarsXE /specs and carsxeSpecs() for each one. Save both responses.
  3. Diff the responses field by field. Expect three kinds of difference: units (inches against millimetres), equipment values (optional becomes empty), and fields we leave undefined. Anything else needs a closer look.
  4. List the VINs where CarsXE returned 404 and Cardog returned valid: true, and the reverse. On the Cardog side, reasons says why a VIN missed.
  5. Switch over only when you have accepted every difference.

What Cardog adds

  • Canadian VINs to trim. We decode 99.77% of Canadian VINs to trim level.
  • Canadian and US prices kept separate. markets.CA (CAD) and markets.US (USD) are separate and never merged.
  • Metric units, with the published number kept. Every number states its unit, and stated holds the source's original when it was published in another unit.
  • Recalls from Transport Canada and NHTSA. Each recall names its issuing authority: tc or nhtsa. The VIN check is GET /v2/vin/{vin}/recalls. CarsXE's recalls page names NHTSA as its source and does not mention Transport Canada (Recalls).
  • A reason for every miss. When valid is false, reasons gives a code from a fixed list, such as CHECK_DIGIT_MISMATCH or UNSEEN_BUILD.
  • Join keys. nano identifies the build, squish the configuration, and refs.* the entities. You can join records on these instead of on name strings.
  • Three-state equipment. optional is a separate value from unavailable.
  • Batch decode. POST /v2/vin/batch takes 1 to 1,000 VINs, and a bad VIN fails only its own row.

FAQ

Does Cardog accept the API key as a query parameter like CarsXE? No. Send it in the x-api-key header or as Authorization: Bearer.

Why does a field CarsXE returned come back empty? Either no source states it for that build, or the catalogue trims behind the build disagree and we will not pick one. The mapping table shows which fields are not mapped.

Why two calls instead of one? Identity and spec sheets are separate resources. nano from the first call is the key for the second.

How much does the two-call flow cost? The identity route is priced at two credits per VIN and each spec sheet request at one credit, so one CarsXE /specs call becomes three Cardog credits. Today a single GET /v2/vin/{vin} bills its two credits for any 200 answer, including valid: false, while a batch bills only the rows that come back valid: true. A 400 costs nothing. See /pricing.

Do I have to use refs? No. Display names (make, model) are in the identity response. Refs are there for joining records.

Sources

Every CarsXE page below was checked on 2026-10-26:

If something here is wrong, tell us and we will fix it.