Car images in a Shopify store
No app needed: signed URLs in product metafields, a small minting function, or copies in Shopify.
There is no Car Image app for Shopify, and a store does not need one: signed URLs load in any web page, a theme included. The three patterns below use only documented features, and each section says what it costs and where it breaks. All three name the car by its stable vehicle id, so start by finding those.
Choose a pattern
- Signed URLs in a product metafield. No hosting of your own. 1 credit per image to mint, then one more per image for each week in which its product page is opened. The images stop when their renewal period ends, and while the balance is empty.
- A small function that mints on demand. A few dozen lines on a serverless platform. At most one credit per image every 6 days, and none while nobody views it; nothing to re-import.
- Copies uploaded to Shopify. One credit per image, once, and nothing to renew. The copies are covered by the license terms above.
Free, Pro and Business also cap how many distinct vehicles an account can render in a month, so size the plan to the catalog as well as to the images: see Pricing & billing.
Before you start: a vehicle id per product
A vehicle id (veh_…) names one make, model and year and never changes. Find it once per product and keep it on the product, for example in a metafield custom.vehicle_id: resolve the product's year, make and model, or decode its VIN. Both lookups are free.
# Free: words to a vehicle id. Use data.params.vehicle_id; ask a person when confidence is low.
curl -X POST https://carimage.dev/api/v1/images/resolve \
-H "Authorization: Bearer $CAR_IMAGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "2024 Porsche 911"}'
# Free: a VIN to a vehicle id. Use data.vehicle.id; it is null when the catalog has no match.
curl -H "Authorization: Bearer $CAR_IMAGE_API_KEY" "https://carimage.dev/api/v1/vin/1HGCM82633A004352"For a whole product list, check it in one go: export make, model and year to a CSV, with your product id in a ref column, and npx @meterapp/car-image check products.csv --out products-checked.csv writes the file back with a vehicle_id beside every row the catalog carries. That is free too; see Backfill a catalog.
A make and model typed from memory can answer 404 for a car the catalog carries under another name, which is why the images are requested by id. See Vehicles & search and VIN decoding.
1. Signed URLs in a product metafield
Mint the URLs when you import or update products, save each in a product metafield, and let the theme print it. Nothing of yours runs after the import. A plain signed URL stops loading after at most 7 days, so the script mints renewing ones: a renewing URL keeps loading after it expires, and the first load in each new week bills one more credit, none for weeks nobody opens the page, for 365 days after minting unless you set a shorter renewal period.
The script needs Node.js 20 or later, @meterapp/car-image-sdk, your API key and a Shopify Admin API access token with the write_products scope. Add a product metafield definition for custom.car_image (type URL) if you want to see the field on products in the Shopify admin.
// node set-car-images.mjs products.json [first index, to resume after an error]
// products.json: [{ "product": "gid://shopify/Product/123", "vehicle": "veh_78qtwrgh37bkr" }, …]
import { readFile } from "node:fs/promises";
import { CarImageClient } from "@meterapp/car-image-sdk";
const client = new CarImageClient({ apiKey: process.env.CAR_IMAGE_API_KEY });
const SHOP = process.env.SHOPIFY_SHOP; // your-store.myshopify.com
const API_VERSION = "2026-07"; // a supported Admin API version
const products = JSON.parse(await readFile(process.argv[2], "utf8"));
// 25 at a time: metafieldsSet takes up to 25 metafields per call.
for (let start = Number(process.argv[3] ?? 0); start < products.length; start += 25) {
const batch = products.slice(start, start + 25);
// One debit, 1 credit per URL. Each URL keeps loading after it expires, at 1 more credit
// for each further week in which it is opened, for 365 days unless you set renewDays.
const { data } = await client.createImageUrls(
batch.map(({ vehicle }) => ({ vehicle, view: "front-3-4", width: 1024, height: 768, trim: true, format: "auto" })),
{ ttlSeconds: 604800, renew: true },
);
const response = await fetch(`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": process.env.SHOPIFY_ADMIN_TOKEN, // needs write_products
},
body: JSON.stringify({
query: "mutation ($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { userErrors { field message } } }",
variables: {
m: batch.map(({ product }, i) => ({
ownerId: product,
namespace: "custom",
key: "car_image",
type: "url",
value: data[i].url,
})),
},
}),
});
const result = await response.json();
const problems = result.errors ?? result.data?.metafieldsSet?.userErrors ?? [];
if (!response.ok || problems.length > 0) {
// This batch was minted and billed; fix the problem, then resume from `start`.
throw new Error(`Stopped at ${start}: ${JSON.stringify(problems)}`);
}
console.log(`${start + batch.length} of ${products.length}, renewing until ${data[0].renews_until}`);
}{%- assign car_image = product.metafields.custom.car_image.value -%}
{%- if car_image != blank -%}
<img src="{{ car_image }}" alt="{{ product.title | escape }}" width="1024" height="768" loading="lazy">
{%- endif -%}- Run it again before the renewal period ends. After that, a URL stops loading; the script prints the date.
- An empty balance breaks the images. A renewal the account cannot pay answers
402, and the image does not load until credits land. Auto-reload in the dashboard prevents the gap. - Cost follows traffic. One credit per image for each week in which its page is opened. A copy uploaded to Shopify costs one credit, once.
2. A small function that mints on demand
Your theme points each image at a function you host. The function holds the key, mints a signed URL the first time an image is asked for, keeps it in a key-value store for 6 of its 7 days and redirects the browser to it. New products work as soon as their vehicle id is on the function's list, and nothing needs re-importing.
// A Cloudflare Worker with a KV namespace bound as CAR_IMAGE_URLS and the API key stored as
// a secret (npx wrangler secret put CAR_IMAGE_API_KEY). Any platform with a shared
// key-value store works the same way.
const TTL = 604800; // 7 days, the longest a signed URL lives
const KEEP = 518400; // hand one URL out for 6 days, so every copy has a day to spare
const VEHICLES = new Set(["veh_78qtwrgh37bkr"]); // the veh_… ids of the cars you sell
const VIEWS = new Set(["front-3-4", "side", "rear-3-4"]);
export default {
async fetch(request, env) {
const params = new URL(request.url).searchParams;
const vehicle = params.get("vehicle") ?? "";
const view = params.get("view") ?? "front-3-4";
// Only your own vehicles: an open endpoint spends your credits for anyone who finds it.
if (!VEHICLES.has(vehicle) || !VIEWS.has(view)) return new Response("Not found", { status: 404 });
const cacheKey = `${vehicle}:${view}`;
let url = await env.CAR_IMAGE_URLS.get(cacheKey);
if (!url) {
const response = await fetch("https://carimage.dev/api/v1/image-urls", {
method: "POST",
headers: { Authorization: `Bearer ${env.CAR_IMAGE_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
images: [{ vehicle, view, width: 1024, height: 768, trim: true, format: "auto" }],
ttl_seconds: TTL,
}),
});
// 402: the balance is empty or the plan's vehicle cap is reached; retrying cannot fix it.
if (response.status !== 201) return new Response("Image unavailable", { status: 503 });
url = (await response.json()).data[0].url; // 1 credit
await env.CAR_IMAGE_URLS.put(cacheKey, url, { expirationTtl: KEEP });
}
// Browsers and CDNs may reuse the redirect for an hour, well inside the URL's lifetime.
return new Response(null, { status: 302, headers: { Location: url, "Cache-Control": "public, max-age=3600" } });
},
};{%- assign vehicle_id = product.metafields.custom.vehicle_id.value -%}
{%- if vehicle_id != blank -%}
<img src="https://images.example.com/?vehicle={{ vehicle_id | url_encode }}&view=side"
alt="{{ product.title | escape }}" width="1024" height="768" loading="lazy">
{%- endif -%}Mint only for vehicles you sell: anyone can call the function, and the terms rule out proxying raw access to the API. For a large catalog, keep that list in the key-value store too. Keep the URLs in a store every instance of the function shares, not in memory: instances come and go, and each one would mint its own.
3. Copies uploaded to Shopify
Download each render once and upload it to Shopify, as product media or under Content → Files. It is the cheapest pattern for a catalog that changes slowly: one credit per image, once, and nothing to renew.
# 1 credit per image; serve the copy from Shopify while your paid plan is active.
npx @meterapp/car-image get --vehicle veh_78qtwrgh37bkr --view side \
--width 1024 --height 768 --trim --out porsche-911-2024-side.pngFor many products, a product CSV import saves the uploads: Shopify downloads each image from the URL in the Image Src column and keeps its own copy. Mint signed URLs for the batch and load each one once before you import. A vehicle, paint and view nobody has asked for before is rendered on its first load, the slow path, and loading it again is fast and free.
# images.json, up to 50 per file: [{ "vehicle": "veh_78qtwrgh37bkr", "view": "side", "width": 1024, "height": 768, "trim": true }, …]
npx @meterapp/car-image url --batch images.json --ttl 86400 > urls.txt # 1 credit per URL, one URL per line, in order
# Load each URL once before the import, so Shopify never waits on a first render. Loading again is free.
xargs -n 1 curl -s -o /dev/null < urls.txt.myshopify.com address and any custom domain) as allowed origins on the minting key's card in the dashboard. A URL copied to another site then answers 403 there; requests with no Referer or Origin pass. See Restrict URLs to your sites.