The problem: the record has a VIN months before it has a photo
A stock record in a CRM, an ERP or a dealer management system starts life as a VIN, a stock number and a price. The photo comes when the lot photographer comes, and for some stock it never comes at all: a unit in transit, a trade-in still being reconditioned, an import whose model name matches nothing in the DMS's image feed, a 1990s car nobody licensed a press shot of. Every screen that shows that record, the salesperson's list, the buyer's appraisal, the customer's quote, shows a grey placeholder instead.
The dealer and import tools on the API fill that placeholder from what the record already has. If it has a VIN, the image is one free decode away. If it only has a line on a stock sheet, free-text resolve gets there. Either way the record ends up holding a stable vehicle id, and every screen renders the same car the same way.
What a dealer integration looks like
- Decode on intake. The VIN is decoded once, when the record is created, and the catalog vehicle id is stored next to it. Nothing downstream spells a make or model.
- One view for the list, one for the detail. A straight
sideat a wide box for inventory lists;front-3-4for the detail page and the quote. - Deep years. Import and used stock reaches back to the 1990s, and it renders like the new arrivals: the catalog spans 1990 to the current model year, Japanese-domestic nameplates included.
- Signed URLs inside the app. The CRM is a browser, so it loads key-free signed URLs minted by the server, up to fifty per call, renewing for records that live longer than a week.
Step 1: decode the VIN, for free
GET /api/v1/vin/{vin} costs nothing and needs only an API key. It returns the decoded year, make, model, trim, body class, engine and every other attribute NHTSA's vPIC data knows, and, when the catalog has the vehicle, a vehicle object with its stable id and a ready-made image_path. Partial VINs work too: at least five characters, * for each unknown position, plus an optional year hint.
curl -s -H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
"https://carimage.dev/api/v1/vin/1HGCM82633A004352"{
"data": {
"vin": "1HGCM82633A004352",
"valid": true,
"year": 2003,
"make": "Honda",
"model": "Accord",
"trim": "EX-V6",
"body_class": "Coupe",
"vehicle": {
"id": "veh_3qfyk22gfhsx3",
"make": "Honda",
"model": "Accord",
"year": 2003,
"image_path": "/api/v1/images/car?vehicle=veh_3qfyk22gfhsx3"
}
},
"request_id": "req_01j9x…"
}trim and body_class as text on the record and show them next to the image, rather than claiming the image depicts them. A VIN with a check-digit error still decodes, with valid: false and the error listed, so a mistyped stock sheet is not a dead end.Step 1b: when all you have is a line on a stock sheet
Import stock often arrives as a spreadsheet: a year, a make, a model name in whatever spelling the auction house used, a grade or trim code. POST /api/v1/images/resolve is free and deterministic: it pulls the year, color and view words out of the phrase, searches the catalog with what is left, and returns exact parameters with a confidence you can act on.
curl -s -X POST https://carimage.dev/api/v1/images/resolve \
-H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "2019 toyota voxy zs side"}'{
"data": {
"params": {
"vehicle_id": "veh_9w0nvw7jphp6r",
"make": "toyota",
"model": "voxy",
"year": 2019,
"view": "side",
"color": "silver"
},
"display": {
"make_name": "TOYOTA",
"model_name": "VOXY"
},
"confidence": "low",
"image_path": "/api/v1/images/car?make=toyota&model=voxy&year=2019&view=side&color=silver"
},
"request_id": "req_01j9x…"
}The vehicle is right (the 2019 Toyota Voxy) but the confidence is low, because "ZS" is a grade the catalog does not know and it had to be matched around. In a batch import, treat high as done, and put medium and low in front of a human with the candidates list. When the sheet already has the year and make in their own columns, send them as the year and make fields and leave only the model in query; the match gets much cleaner. A phrase with no vehicle words is a 400, and a make with no matching model is a 404 that lists the make's years so you can ask for the model.
Step 2: keep the vehicle id on the record
Whichever route got you there, store vehicle.id (or params.vehicle_id) on the stock record as a plain string column. It is deterministic and permanent: the same vehicle has the same id on every deploy and after every catalog release, so it is safe as a foreign key. From then on every image request is vehicle=veh_…, which cannot be misspelled, and a make renamed by a manufacturer or re-filed by a source never breaks a stored URL.
Step 3: one view for the list, one for the detail page
Inventory lists are wide and short, so the straight side view in a 2:1 box is the usual choice: every unit faces the same way and sits on the same baseline. trim=1&padding=6 crops to the car before fitting, so a Hilux and a Yaris fill the row the same way; fit=contain keeps the canvas exactly 640×320 with transparent margins. The detail page uses front-3-4 at 1024, and the two share a cache: a paint you did not specify is silver on both.
curl --fail-with-body -H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
"https://carimage.dev/api/v1/images/car?vehicle=veh_3qfyk22gfhsx3&view=side&w=640&h=320&fit=contain&trim=1&padding=6&format=webp" \
-o stock-4471.webpStep 4: show it in the CRM through signed URLs
The CRM runs in a browser, and a browser must never hold the API key. When a screen lists a page of stock, the server mints one signed URL per record with POST /api/v1/image-urls (up to fifty per call, one credit each) and the page loads them like any image. Two settings matter for a record that stays in stock for months:
ttl_seconds: 604800withrenew: true: the URL keeps working past the seven-day TTL, and each further week in which somebody actually opens the record costs one more credit. Weeks nobody looks at the car cost nothing.- An
Idempotency-Keyper page of the sync: a retried request replays the first response and mints nothing twice.
Restrict the minting key to the CRM's origin on the dashboard and a URL that leaks into another site answers 403. Revoking the key invalidates every URL it minted. If the balance ever runs dry, a renewal answers 402 and the same URL resumes as soon as credits land; auto-reload removes that failure mode. The full lifecycle is on the signed URL docs.
import { CarImageClient } from "@meterapp/car-image-sdk";
const client = new CarImageClient({ apiKey: process.env.CAR_IMAGE_API_KEY });
// On intake: VIN in, vehicle id on the record. Free.
const { data: decoded } = await client.decodeVin(stock.vin);
if (decoded.vehicle) {
stock.vehicleId = decoded.vehicle.id; // veh_…, permanent; the record never spells a car again
stock.trim = decoded.trim; // shown next to the image, never claimed by it
}
// When the CRM draws the stock list: one signed URL per record, up to fifty per call.
// The browser loads them; the key never leaves the server.
const { data: urls } = await client.createImageUrls(
page.map((row) => ({
vehicle: row.vehicleId, view: "side",
width: 640, height: 320, fit: "contain", trim: true, padding: 6, format: "auto",
})),
{ ttlSeconds: 604_800, renew: true, renewDays: 365, idempotencyKey: `stock-list:${syncRunId}:${pageIndex}` }
);CLI, SDK and MCP
# decode for free; the output names the vehicle id and a ready-to-run image command
npx @meterapp/car-image vin 1HGCM82633A004352
npx @meterapp/car-image vin 1HGCM82633A004352 --json | jq -r .data.vehicle.id
# a partial VIN from a faded door sticker: at least five characters, * for each unknown position
npx @meterapp/car-image vin 1HGCM826*3A --year 2003
# the list image for one record
npx @meterapp/car-image get --vehicle veh_3qfyk22gfhsx3 --view side --width 640 --height 320 --fit contain --trim --padding 6 --format webp --out stock-4471.webp- SDK:
client.decodeVin(vin, { year? }),client.resolve(query),client.getImage({ vehicle, … })andclient.createImageUrls(…), as above. - MCP:
decode_vin,resolve_vehicle,get_car_imageandcreate_car_image_urlsare in the core toolset, so an assistant with the stock system in context can answer "photo for stock 4471" from the VIN on the record. Setup is on the MCP page; the decoder's fields and errors are on the VIN docs.
Frequently asked questions
- What does a photo on every stock record cost?
- The VIN decode is free. The first image of a vehicle, view and paint costs one credit and is cached for everyone after that; re-checking it with If-None-Match is free. A signed URL costs one credit when it is minted and, with renew: true, one more credit for each further week in which it is actually opened. A dealer group with a few thousand units in stock spends a few thousand credits once, then almost nothing.
- The VIN decodes but vehicle is null. Now what?
- The decoder knew the make, model and year but the catalog has no entry for that combination: a trailer, some commercial chassis, a nameplate the sources have not filed yet. Show the decoded fields, search the catalog with GET /api/v1/vehicles?q= in case the spelling differs, and request the vehicle on the requests board; POST /api/v1/requests upvotes an open request instead of duplicating it.
- Does VIN decoding cover imports and vehicles never sold in the US?
- The decoder is NHTSA's vPIC data, so a VIN from a manufacturer that files with NHTSA decodes fully. A vehicle never certified for the US often decodes to a manufacturer and year with no model, and some do not decode at all. For that stock, resolve from the registration document instead: the catalog itself is worldwide, from 1990, and Japanese-domestic nameplates such as the Toyota Voxy are in it.
- Does the render show the trim on the record?
- No. The catalog keys on make, model and year, so an EX-V6 coupe and an LX sedan render as the same 2003 Accord. Keep trim and body_class from the decode as text next to the image, and never describe the render as the specific unit.
- Can the dealer website use the same URLs as the CRM?
- Mint them from a key whose allowed origins list the site, and a URL that a browser loads from any other site answers 403 origin_not_allowed. Loads with no Referer, such as an email client, pass. The CRM and the website can share one key and one set of URLs, or use one key each so either can be revoked alone.