Signed URLs
POST /api/v1/image-urls — browser-safe URLs billed once at creation.
Signed URLs let a browser, an email or a CMS load an image without ever seeing your API key. Your backend mints them; anyone holding the URL can open it until it expires. Each URL costs one credit at creation, no matter how many times it is opened within its TTL.
Create URLs
/api/v1/image-urls| Parameter | Type | Description |
|---|---|---|
imagesrequired | ImageRequest[] (1–50) | Each item takes the same fields as GET /api/v1/images/car: make, model, year (required), view, color, size, width, height, fit, background, framing, trim, padding, shadow, format. Both dimensions give an exact width×height canvas (fit decides how); format: "auto" makes the URL negotiate WebP or PNG on every load. A single object is also accepted as the whole body. |
ttl_seconds | integer 1–604,800 | How long the URLs stay valid. Maximum seven days.Default: 3600 |
max_uses | integer 0–1000 | Cap on redemptions per URL. 0 means unlimited within the TTL; 1 makes a single-use link.Default: 0 |
renew | boolean | Auto-renew: keep the URLs valid past expires_at, billing one more credit for each further window of ttl_seconds in which a URL is loaded. Requires max_uses: 0. See auto-renewing URLs.Default: false |
renew_days | integer 1–365 | How long a renewable URL keeps renewing, from creation. Only with renew: true.Default: 365 |
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" \
-d '{"images":[{"make":"porsche","model":"911","year":2024,"view":"front-3-4","color":"red"}],"ttl_seconds":3600}'{
"data": [
{
"id": "3f0c2a7e-1c1e-4c2a-9c39-4f1f0a4b2d10",
"url": "https://carimage.dev/api/v1/delivery/eyJhbGciOi…",
"expires_at": "2026-09-02T18:00:00.000Z",
"max_uses": 0,
"renews_until": null,
"vehicle": {
"make": "porsche",
"model": "911",
"year": 2024,
"view": "front-3-4",
"color": "red",
"format": "png"
}
}
],
"billing": {
"charged_on": "creation",
"credits_per_url": 1,
"credits_charged": 1,
"credits_remaining": 4869,
"renewal": null
},
"request_id": "req_01j9x…"
}The echoed vehicle carries the delivery options you set: width, height, fit (only with a box), background, trim, padding and shadow (a framing preset is echoed as the trim and padding it stands for), so a caller can tell one URL from another without decoding the token.
Display a signed URL in HTML or React
A signed delivery URL works as an ordinary image source in HTML and React. No browser SDK is needed. Your server or build step creates it with POST /api/v1/image-urls; only the returned data[0].url reaches the page. Look the vehicle up first and store its vehicle id.
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: listing-911-hero-v1" \
-d '{"images":[{"vehicle":"veh_78qtwrgh37bkr","view":"front-3-4","width":600,"height":400,"trim":true,"format":"auto"}],"ttl_seconds":604800}'<!-- Replace SIGNED_DELIVERY_URL with the complete data[0].url from your server. -->
<img
src="SIGNED_DELIVERY_URL"
alt="2024 Porsche 911, front three-quarter view"
width="600" height="400"
loading="lazy" decoding="async"
style="max-width: 100%; height: auto;"
>// Pass the complete data[0].url from your server as src.
// Works in React, including Vite apps and Next.js Client Components.
export function CarImage({ src, alt }: { src: string; alt: string }) {
return (
<img
src={src} alt={alt}
width={600} height={400}
loading="lazy" decoding="async"
style={{ maxWidth: "100%", height: "auto" }}
/>
);
}
// deliveryUrl is the signed URL your server returned and stored.
<CarImage src={deliveryUrl} alt="2024 Porsche 911, front three-quarter view" />Keep the API key on the server. Reuse the URL; do not mint during React client rendering or in a browser effect. One URL costs one credit to create; repeated loads are free until expires_at. This example requests a seven-day lifetime. Replace it before expiry, or choose auto-renewal for longer-lived pages (one more credit per opened renewal window). Set image options when creating the URL; use the returned URL unchanged.
For server-side minting, caching and placeholders, follow the React and Next.js guide. For static HTML, follow Pages without a backend.
Retries are safe: Idempotency-Key
URLs are billed when they are minted, so a retry after a dropped connection would normally mint and bill a second batch. Send an Idempotency-Key header (1–255 characters: letters, digits, ., _, : or -) and the same key with the same body within 24 hours replays the original response — same status, same body — with Idempotent-Replayed: true and no second charge. Keys are scoped to your account and this route; a 5xx is never stored, so a retry after a server error runs the request again.
- Same key, different body:
422 Idempotency-Key reused. Send a new key for a new request. - Same key while the first request is still running:
409 Request in progresswithRetry-After: 2.
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: listing-8812-hero" \
-d '{"images":[{"make":"porsche","model":"911","year":2024,"width":600,"height":400,"trim":true}]}'
# run it twice: the second answer is the first one, with Idempotent-Replayed: true| Header | Example | Meaning |
|---|---|---|
Idempotency-Key | listing-8812-hero | Request header. Any string unique to this request: an order id, a UUID, a content hash. |
Idempotent-Replayed | true | Response header on a replay. Status and body are the original's; no credits were charged. |
Retry-After | 2 | On a 409: seconds to wait before retrying the same key. |
The SDK (@meterapp/car-image-sdk 1.4.0) sends a generated key on every createImageUrls call unless you pass idempotencyKey, so its own retries are always safe; the CLI (@meterapp/car-image 1.3.0) takes car-image url --idempotency-key <key>; the MCP tool create_car_image_urls takes idempotency_key and answers a retry with idempotent_replayed: true.
Redeem a URL
/api/v1/delivery/{token}Redemption needs no key and is not billed. Drop the URL straight into an <img>:
<img
src="https://carimage.dev/api/v1/delivery/eyJhbGciOi…"
alt="2024 Porsche 911, red, front three-quarter"
width="1024" height="1024" loading="lazy"
/>| Header | Example | Meaning |
|---|---|---|
Cache-Control | public, max-age=300, s-maxage=3421, must-revalidate | Unlimited-use URLs are publicly cacheable: CDNs hold the bytes for the remaining lifetime (up to a day), browsers revalidate within five minutes, and every repeat is free. Use-capped URLs are private, no-store. |
ETag | "9f2a…c41d" | Same stable tag as the keyed endpoint. |
X-Image-Width / X-Image-Height | 600 / 400 | The delivered canvas: exactly width×height for a contain or cover box, otherwise what the render produced (a trimmed car follows its own aspect ratio). |
Vary | Accept | Only on URLs minted with format: "auto": WebP for clients that accept it, PNG for the rest, negotiated on every load. |
X-Image-Source | cache | Whether the underlying render was a CDN hit or a cold render. |
Restrict URLs to your sites
An unlimited-use URL is public for its lifetime: anyone who copies it from your page can embed it elsewhere until it expires, served from the credit you paid at creation. To keep that to your own sites, give the key that mints the URLs a list of allowed origins on its card in the dashboard: hostnames such as example.com, or *.example.com for every subdomain and the apex. A URL minted by such a key answers 403 with "code": "origin_not_allowed" to a browser whose Referer or Origin header names another site.
- Requests with no
RefererorOriginpass. Email clients, PDF viewers, curl and browsers with a strict referrer policy send neither, so a renewable URL in an email keeps working. - Hygiene, not identity. Both headers are set by the client, so this stops casual hotlinking and a URL pasted into another site, not a determined copier. The token is still the secret; use
max_usesand a short TTL for anything sensitive. - Per key, checked at redemption. The list is read when the URL is loaded, so editing it applies to URLs already minted, and a key with an empty list allows every origin. Keys used only from your own servers need no list.
Auto-renewing URLs for email, CMS pages and documents
A page is re-rendered on every visit, so a seven-day URL is easy to refresh. An email, a PDF or a CMS page nobody re-publishes is rendered once and opened whenever the reader gets to it. For those surfaces, mint with renew: true: the URL is created and charged like any other, and when it is loaded after expires_at the first load in each new window of ttl_seconds bills one more credit and renews it, until renews_until (default and maximum 365 days).
- One credit per window in which the URL is actually opened. Windows nobody opens cost nothing. With the seven-day maximum TTL the ceiling is one credit per image per week.
- Billed once per window. The ledger reference
renew:<id>:<window>is unique, so fifty opens in a week, or two mail proxies racing on the first, pay once. - Same caching as the first window. Renewed windows answer with the same public
Cache-Control, bounded by the window end. - Empty balance: 402, not 403. A renewal the account cannot pay answers
402 Insufficient creditsand the URL resumes on its next load once credits land; auto-reload on the dashboard prevents the gap. Revoking the key still ends the URL with 403. - Not with use caps.
renewrequiresmax_uses: 0.
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" \
-d '{"images":[{"make":"porsche","model":"911","year":2024,"width":480}],"ttl_seconds":604800,"renew":true}'{
"data": [
{
"id": "3f0c2a7e-1c1e-4c2a-9c39-4f1f0a4b2d10",
"url": "https://carimage.dev/api/v1/delivery/eyJhbGciOi…",
"expires_at": "2026-09-21T09:00:00.000Z",
"max_uses": 0,
"renews_until": "2027-09-14T09:00:00.000Z",
"vehicle": {
"make": "porsche",
"model": "911",
"year": 2024,
"view": "front-3-4",
"color": "silver",
"width": 480,
"format": "png"
}
}
],
"billing": {
"charged_on": "creation",
"credits_per_url": 1,
"credits_charged": 1,
"credits_remaining": 4869,
"renewal": {
"window_seconds": 604800,
"credits_per_window": 1,
"until": "2027-09-14T09:00:00.000Z"
}
},
"request_id": "req_01j9x…"
}Lifecycle and errors
403— the token is malformed, expired, or the key that minted it was revoked. Mint a new URL.403with"code": "origin_not_allowed"— the minting key restricts origins and the page loading the URL is not on its list. See Restrict URLs to your sites.410— the URL hit itsmax_usescap. Mint a new URL.402— a renewable URL entered a new window and the account could not pay the credit. Add credits; the URL resumes on its next load.502— rendering failed on first delivery. The creation credit is refunded; mint a new URL before retrying.413— the JSON body exceeded 64 KiB. Split the batch; each call takes up to 50 images.409/422— anIdempotency-Keyreused while its request is still running, or with a different body. See safe retries.