A build guide written from the API's documented behavior; it names no customer. Published .
The problem: the sale is live before the photos are
An auction catalog goes up as soon as the consignments are in: a VIN, a lot number and a sale date for each car. The photos come when the car reaches the yard and the photographer reaches the car, which for part of a sale is after bidding opens. Until then each of those lots is a grey box beside a line of text.
A catalog render fills the box with the right model year. An auction is also exactly where a render must never pass for the car: bidders price condition, and a render shows none, because it does not depict any specific physical vehicle. Everything below follows from holding both of those at once.
What the integration looks like
- A render per lot from its VIN, shown only until the lot's own photos arrive.
- A catalog picker for the lots whose VIN does not decode.
- A label on the picture: "Catalog render, not this lot".
- One URL per lot, minted once under an
Idempotency-Key, living as long as the sale.
Step 1: from the lot's VIN to a vehicle id
GET /api/v1/vin/{vin} is free. When the catalog has the vehicle, the answer carries data.vehicle.id, the stable id to store on the lot; the decoded trim, body and engine stay in the lot's text. The catalog spans 1990 to 2027, so the older stock of an auction renders like the newest. The insurance guide walks through a decode field by field, and the VIN docs cover partial VINs and errors.
# the lot's VIN to the vehicle id the lot stores (free); nothing printed means no catalog match
npx @meterapp/car-image vin "$LOT_VIN" --json | jq -r '.data.vehicle.id // empty'Step 2: no decodable VIN? Pick the vehicle from the catalog
Some lots arrive without a VIN that decodes: a vehicle built for another market, a VIN the source typed wrong, a consignment sheet with a year, a make and a model and nothing else. Give the consignment form three dropdowns fed by GET /api/v1/vehicles, which is free and public: ?year= lists the makes with a model in that year, and ?year=&makeId= lists that make's models, each with the vehicle id of that model year.
# every make with a 2006 model year (free, public)
curl -s "https://carimage.dev/api/v1/vehicles?year=2006"
# {"data":{"year":2006,"makes":[…,{"id":476,"name":"DODGE","slug":"dodge"},…]},…}
# that make's 2006 models, each with the id of its 2006 model year
curl -s "https://carimage.dev/api/v1/vehicles?year=2006&makeId=476"{
"data": {
"year": 2006,
"make_id": 476,
"models": [
{
"name": "Caliber",
"slug": "caliber",
"vehicle_type": "Passenger Car",
"id": "veh_294vq81yq0d3y"
},
{
"name": "Charger",
"slug": "charger",
"vehicle_type": "Passenger Car",
"id": "veh_96j8z47jqfd4r"
},
"…"
]
}
}The same catalog is the open @meterapp/vehicle-db package, so the dropdowns can also run offline against the identical list. Either way the lot ends up holding the same kind of id as a decoded VIN, and nothing downstream spells a car.
When a whole consignment sheet arrives at once, POST /api/v1/vehicles/check reads up to 2,000 entries per call, each as the sheet spells it and with the lot number as its ref, and answers every one: a match with the id to store, suggestions, a miss or an invalid row. Nothing is rendered and nothing is charged; the vehicles docs have the details.
Step 3: label the render as a render
Put the label on the picture itself, where a bidder's eye lands, and in the alt text, not only in the description underneath. Condition, mileage, damage, options and the paint on the car belong in the lot text and, once they exist, the photos. When the photos arrive, the render goes.
<figure class="lot-image">
<img src="https://carimage.dev/api/v1/delivery/eyJhbGciOi…" width="800" height="450"
alt="Catalog render of a 2006 Dodge Charger, not a photo of lot 117">
<span class="lot-image__badge">Catalog render, not this lot</span>
</figure>
<p>Photos of this lot are on their way. Condition, mileage and damage are in the report.</p>Step 4: one URL per lot, minted once
Mint the lot's URL at intake with POST /api/v1/image-urls and an Idempotency-Key made from the sale and the lot, in letters, digits, ., _, : and -. The URL is paid for when it is minted (1 credit), so without a key a retried intake would mint and pay twice. With one:
- The same key with the same body within 24 hours replays the first response, the same URL included, with
Idempotent-Replayed: trueand no second charge. - The same key with a different body is a
422, and a retry that overtakes the first request is a409with aRetry-After. So fix everything in the body, the lifetime included, when the lot is created, and store it with the lot. - Store the URL on the lot too: the key protects retries, and the stored URL is what the pages read for the rest of the sale.
A whole catalog imported at once can mint up to 50 lots per call, with a key per batch; each URL in the answer echoes the vehicle and options it was minted for.
curl --fail-with-body -X POST https://carimage.dev/api/v1/image-urls \
-H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sale-2026-10-14:lot-117" \
-d '{"images":[{"vehicle":"veh_96j8z47jqfd4r","view":"side","width":800,"height":450,"fit":"contain","trim":true,"padding":6,"format":"auto"}],"ttl_seconds":259200}'
# run it again: the same URL comes back with Idempotent-Replayed: true, and nothing more is chargedStep 5: match the URL's lifetime to the sale
ttl_seconds runs up to 604800 (7 days); a longer value is clamped to that. For a sale that closes within the week, set it to the time left until the close. The placeholder then stops working when bidding does, and after that the URL answers 403.
- A timed sale longer than 7 days: mint with
renew: true,ttl_secondsat the maximum andrenew_dayscovering the sale. Each further week in which the lot is opened costs 1 credit, quiet weeks cost nothing, and renewal stops atrenews_until. - Keep
max_usesat 0, the default: an unlimited URL is publicly cacheable for its lifetime, while a capped one isprivate, no-storeand sends every load to the origin. - Restrict the minting key to your auction's domains on the dashboard: a lot image copied onto another site answers
403.
import { CarImageClient, type ImageParams } from "@meterapp/car-image-sdk";
const client = new CarImageClient({ apiKey: process.env.CAR_IMAGE_API_KEY });
const lotImage = (vehicle: string): ImageParams => ({
vehicle, view: "side", width: 800, height: 450, fit: "contain", trim: true, padding: 6, format: "auto",
});
// How long the lot's URL lives, decided once from the sale's close.
function lifetime(closesAt: Date) {
const seconds = secondsUntil(closesAt);
return seconds <= 604800
? { ttlSeconds: Math.max(seconds, 60) } // closes within the week
: { ttlSeconds: 604800, renew: true, renewDays: Math.min(daysUntil(closesAt), 365) }; // a longer timed sale
}
// At intake, once per lot. The lifetime is stored with the lot, so a retry sends
// the same body and replays the first answer instead of minting a second URL.
lot.imageLifetime ??= lifetime(sale.closesAt);
const { data: [placeholder] } = await client.createImageUrls(lotImage(lot.vehicleId), {
...lot.imageLifetime,
idempotencyKey: `${sale.id}:lot-${lot.number}`,
});
lot.placeholderUrl = placeholder.url; // replaced by the lot's own photos when they arrivePlan for the size of the sale
Each lot is a distinct vehicle unless two lots share a make, model and year, and each plan caps the distinct vehicles a month: Free 100, Pro 2,500 and Business 15,000; Enterprise has no cap. A vehicle already served this month is always allowed, and a batch that would pass the cap is refused whole, before anything is charged, with the plan and the cap in the answer. Size the plan to the lots you list in a month, not to the credits. The marketplace story covers pacing a large import under the rate and render limits.
Frequently asked questions
- Can the render stay up once the lot's photos arrive?
- Replace it. The render is a placeholder for the model year, and a bidder should never have to wonder which picture is the car being sold. If a page keeps it for reference, the label goes with it.
- What happens to the URL after the sale?
- It expires when its lifetime ends, or when a renewing URL reaches the end of its renewal, and then answers 403. By then the lot has its own photos or a result, and the archive of the sale should show the photos. Revoking the key that minted the URLs ends them all at once.
- A retry answered 422. Why?
- The same Idempotency-Key came back with a different body. The usual cause is a lifetime recomputed from the clock at the moment of the retry. Fix the lifetime when the lot is created and store it, and the retry sends the same body and replays the first answer.
- Can the render show the lot's paint?
- Yes: color takes any of the preset names or a hex, so the picture can match the paint on the consignment sheet at no extra cost. It is still the model in that paint, not the lot, so the label stays.
- What does a sale of 400 lots cost?
- 400 credits: 1 credit per lot when its URL is minted, and a retried mint charges nothing. Every load within the URL's lifetime is free. Plans cap distinct vehicles a month (Free 100, Pro 2,500 and Business 15,000; Enterprise has no cap), and lots that share a make, model and year count once.