The problem: a listing needs a picture before the seller uploads one
A classifieds site or a car-shopping catalog lives or dies by its grid. Every card needs an image the moment the listing exists, and the seller's own photo arrives later, if at all, at a random angle on a random driveway. Stock photography has the opposite problem: a licensed press shot exists for the popular cars in the popular years, in whatever angle and background the manufacturer chose, and for nothing else. A catalog that covers 1990 to the current model year has tens of thousands of gaps.
What the teams building these sites want is boring in the best way: one studio image per model year, the same angle and the same paint for all of them, transparent so it sits on whatever the card's background is, small enough to serve ten thousand times a day.
What a catalog integration looks like
The catalog builds running on the API today all have the same shape, whether they are a classifieds site seeding its listings, a car-shopping app rendering its comparison cards, or a mobile app showing one hero image per vehicle:
- A backfill, then a trickle. Thousands of renders in a few hours to cover the catalog, then a handful a day as new model years and requests come in.
- One angle, one paint, or a short palette. Most pick
front-3-4orsidein silver or white. A shopping catalog that lets the user pick a color renders a few presets per vehicle; each is one more credit, not a new pipeline. - WebP at a fixed box. 512 to 1024 pixels wide, delivered in exactly the box the card uses, so the front end never crops or letterboxes.
- Stored by vehicle id. The renders land on the team's own object store or CDN, or the pages load signed URLs; either way the key is the catalog's stable
veh_…id, not a make-model-year string.
The rest of this page is that pipeline, step by step, with the code. It assumes an API key with the images:read scope and a plan sized to the catalog (see the FAQ).
Step 1: enumerate the catalog by id, never by spelling
The most common failure in a catalog build is not rendering, it is spelling. A build that generates make and model strings from another database can spend most of its requests on 404 vehicle_not_found: free, but a wasted round trip each, and a grid with holes in it. The fix is to let the catalog tell you what exists. GET /api/v1/vehicles is free, needs no key, and every model it returns carries a stable vehicle id (veh_ plus 13 characters) that never changes across deploys or catalog releases. Store that id on the listing and render by it.
# every make with a 2024 model year (free, no key)
curl -s "https://carimage.dev/api/v1/vehicles?year=2024"
# every 2024 model of one make, each with the id you will store
curl -s "https://carimage.dev/api/v1/vehicles?year=2024&makeId=584"{
"data": {
"year": 2024,
"make_id": 584,
"models": [
{
"name": "718",
"slug": "718",
"vehicle_type": "Passenger Car",
"id": "veh_b4bf090fyb53k"
},
{
"name": "911",
"slug": "911",
"vehicle_type": "Passenger Car",
"id": "veh_78qtwrgh37bkr"
}
]
}
}?make=porsche&model=911 lists every year of one model with an id per year, and ?q= searches free text when a seller types a name. The same catalog is published as the open @meterapp/vehicle-db package, so dropdowns and validation can run offline against the identical list. Either way, by the end of this step you hold a list of ids, and nothing downstream ever spells a car again.
Step 2: one request shape for every listing
Decide the card once and encode it in the query string. Everything after vehicle= below is a framing decision you make one time for the whole catalog:
curl --fail-with-body -H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
"https://carimage.dev/api/v1/images/car?vehicle=veh_78qtwrgh37bkr&view=front-3-4&color=silver&w=800&h=600&fit=contain&trim=1&padding=8&format=webp" \
-o veh_78qtwrgh37bkr.webpw=800&h=600&fit=containdelivers exactly 800×600 with the whole car visible and transparent padding around it, so every card has the same canvas.fit=coverfills the box and center-crops instead.trim=1&padding=8crops to the car's outline before fitting, with 8% of its longer side as margin, so a Suburban and a Miata fill the box the same way instead of floating in the source frame.color=silveris the default and the safest neutral; any of the 15 presets or any hex costs the same one credit.format=webpis the smallest lossless-alpha encoding. When a browser loads the URL directly (signed URLs, step 4),format=autonegotiates WebP or PNG per client.
The response carries ETag, X-Image-Source (cache or generated) and X-Vehicle-Id, the catalog vehicle that was served. Keep the ETag with the file: a later request with If-None-Match answers 304 and costs nothing, which is how a nightly re-sync stays free.
Step 3: pace the backfill inside the limits
Three limits shape a backfill, and every one of them announces itself in headers rather than failing silently:
- Requests per minute. 120 per key on Free and Pro, 600 on Business, 1,200 on Enterprise, plus a per-account limit across every key. Every authenticated response carries
RateLimit-Remaining; a429carriesRetry-After. Four to eight requests in flight is plenty. - Cold renders. The first request for a variant generates it, which takes a few seconds; every later request is served from the cache. New renders draw on the account's share of the day's render budget (2% on Free, 10% on Pro, 25% on Business, 50% on Enterprise). Past it, a new render answers
429withcode: account_generation_capandreset_at(midnight UTC), nothing is charged, and cached images keep serving. A large backfill is a few evenings, not one. - Distinct vehicles a month. Free allows 100, Pro 2,500, Business 15,000, Enterprise has no cap. A request for a vehicle beyond the cap answers
402 plan_vehicle_limitbefore anything is charged; a vehicle already served this month is always allowed, so a live grid never breaks.
Rendering failures (502) are refunded automatically, concurrent requests for the same new variant share one generation, and a plain GET is safe to retry. The SDK retries 429 and 503 for you, honoring Retry-After, and never retries a 402.
import { CarImageClient, CarImageError } from "@meterapp/car-image-sdk";
const client = new CarImageClient({ apiKey: process.env.CAR_IMAGE_API_KEY });
// One make, one model year: the ids are what you store and what you render by.
const { data } = await client.vehicles({ year: 2024, makeId: 584 });
const queue = data.models.flatMap((model) => (model.id ? [model.id] : []));
// Four in flight stays well inside 120 requests a minute, and a cold render
// that takes a few seconds never holds up the rest of the batch.
await Promise.all(
Array.from({ length: 4 }, async () => {
for (let id = queue.shift(); id; id = queue.shift()) {
try {
const image = await client.getImage({
vehicle: id, view: "front-3-4", color: "silver",
width: 800, height: 600, fit: "contain", trim: true, padding: 8, format: "webp",
});
await store(id, image.bytes, image.etag); // your object store or CDN, keyed by vehicle id
} catch (error) {
// 429 and 503 were already retried with Retry-After; a 404 is a vehicle to request, not a failure.
if (error instanceof CarImageError && error.status === 404) continue;
throw error;
}
}
})
);Step 4: serve it from your CDN or through signed URLs
There are two good ways to get the image in front of a shopper, and the catalog builds use both:
- Store the bytes. Write each render to your own object store or CDN under its vehicle id and serve it like any other asset. This is the right answer for a grid that renders millions of times: no per-load call to us, and the license on a paid plan covers copies you keep to serve your product.
- Mint signed URLs.
POST /api/v1/image-urlstakes up to fifty images per call, charges one credit each at creation, and returns key-free URLs the browser loads directly. Setttl_secondsup to seven days; addrenew: truefor listing pages that live longer than that (one more credit per image per week in which it is actually opened, none for quiet weeks). Restrict the key to your site's origins on the dashboard and a URL copied elsewhere answers403.
Send an Idempotency-Key per page of listings when you mint in batches: a retried call with the same key and body replays the first response and charges nothing.
{
"images": [
{
"vehicle": "veh_78qtwrgh37bkr",
"view": "front-3-4",
"color": "silver",
"width": 800,
"height": 600,
"fit": "contain",
"trim": true,
"padding": 8,
"format": "auto"
},
{
"vehicle": "veh_b4bf090fyb53k",
"view": "front-3-4",
"color": "silver",
"width": 800,
"height": 600,
"fit": "contain",
"trim": true,
"padding": 8,
"format": "auto"
}
]
}Authorization headers. A key that reaches a page can be read by anyone who opens the developer tools.When a vehicle is not in the catalog
Search first: GET /api/v1/vehicles?q= finds a model by name, prefix or fuzzy match, and POST /api/v1/images/resolve turns a free-text phrase into exact parameters with a confidence you can act on. What is still missing after that belongs on the requests board: POST /api/v1/requests creates the request or, when an open one exists for the same make, model and year, upvotes it instead of duplicating it. The catalog itself is open source, and additions ship to every customer.
CLI, SDK and MCP
The whole pipeline runs from the CLI with no code at all, which is how more than one catalog on the API was seeded:
# One file per vehicle id. Re-running skips what exists, so a stopped backfill resumes.
while read -r id; do
[ -f "cdn/$id.webp" ] || npx @meterapp/car-image get --vehicle "$id" \
--view front-3-4 --color silver --width 800 --height 600 --fit contain \
--trim --padding 8 --format webp --out "cdn/$id.webp"
done < vehicle-ids.txt
# Or fifty signed URLs in one call, for a page of listings the browser loads itself.
npx @meterapp/car-image url --batch listings.json --ttl 604800 --renew --json- SDK:
client.vehicles({ year, makeId })to enumerate,client.getImage({ vehicle, … })to render,client.createImageUrls(…)for batches of signed URLs. See the image docs for every parameter. - MCP: an assistant connected to
https://carimage.dev/api/mcphas the same catalog (search_vehicles,resolve_vehicle) and the same renders (get_car_image,create_car_image_urls), which is the right tool for a curator adding a vehicle by hand, and the wrong one for a backfill of thousands: use the CLI or the SDK for that and keep the assistant for the stragglers. Setup is on the MCP page.
Frequently asked questions
- Which angle should a listing use?
- front-3-4 is the default and the classic hero angle, nose to the left. Every angle of the car's left side has a -right twin (front-3-4-right, side-right, rear-3-4-right), so a card can face into the layout instead of out of it. Pick one and keep it: the consistency across listings is most of the value.
- What does a catalog of 20,000 listings cost?
- 20,000 credits, once: one credit per vehicle and view, cached forever after, and re-checking with If-None-Match is free. Pro includes 25,000 credits a month but allows 2,500 distinct vehicles a month; Business allows 15,000 and Enterprise has no cap. A backfill that names more new vehicles than the plan allows answers 402 plan_vehicle_limit and charges nothing, so size the plan to the catalog, not to the credits.
- Can we keep the files on our own CDN?
- Yes. A paid plan licenses the renders for use inside your product while the plan is active, copies on your own servers or CDN included, with no attribution. What the license does not allow is repackaging them as a standalone library, feed or dataset, or using them after the plan ends: stored copies are deleted within 30 days of that.
- What happens when the catalog gets a new model year?
- The @meterapp/vehicle-db catalog is refreshed as sources publish, and existing ids never change. Enumerate the new year, render the ids you do not have yet, and leave the rest alone. Requests carry an ETag; a repeat request with If-None-Match answers 304 and costs nothing.
- Why do some of our make and model strings come back 404?
- Because they were spelled by another database. The catalog accepts names, slugs and many badge and body-style variants, but a string it has never seen is a 404 vehicle_not_found (free, but a wasted round trip). Enumerate the catalog and store its ids instead of translating your own strings, and search or resolve the stragglers.