Developers
API integration guide
Everything in the product is available over a REST API with the same pricing. Create an API key on your account page, then follow the guide below.
Generate industry-specific marketing media (hero shots, flyers, posts, ad creatives, boards) from the photos you already have. Every result is checked by a reviewer: prices, phone numbers and names are read back and must match exactly; the subject (car, dish, product, room) stays exactly what you photographed.
Catalogue: GET /api/v1/industries lists every industry with its live use-cases and roadmap;
GET /api/v1/usecases?industry=restaurant returns the use-cases of one industry with their JSON
input schema. Use-case ids are <industry>/<slug>, e.g. restaurant/menu-post.
Base URL: https://zivdeo.com
1. Authentication
Every request: Authorization: Bearer <API_KEY> (keys start with b2bm_; create them on the
Account page). Keys are scoped to one organization; that organization's saved brand (logo,
contacts, colors) is applied automatically.
Keep API keys on your servers. A phone gets a short-lived capture session token instead (starts with zcs_,
§7a); it is accepted only by the few routes listed there and answers 403 scope_denied everywhere else.
Request body shape for POST /api/v1/generate:
{
"usecase": "car-dealership/price-drop-flyer",
"input": { … use-case fields, see section 4 … },
"brandId": "Dnzj4Qdg2koJNMveLa36", // optional: saved brand, "none", or omit for the default
"brand": { "phone": "…" }, // optional: inline overrides
"webhook": { "url": "https://…", "metadata": { … } }, // optional
"showroomConsent": false, // optional
"id": "client-generated-uuid", // optional, makes retries idempotent
"tier": "quality" // optional: "quality" (default) | "value"; omit to use your saved default
}
Quality tiers
Car-dealership use-cases run at one of two tiers; every other industry and every personal use-case runs at
quality only for now (tiers on each use-case in GET /api/v1/usecases and the capabilities document lists what
it offers; a tier it does not offer is silently replaced by the one it does). quality (default) uses the strongest render models with
up to two re-renders; value uses cheaper open-weights editors for photo edits (same director and reviewer) and
one re-render. Designed pieces (flyers) on the value tier are composed by our layout engine
instead of an image model: your car is cut out and placed on a studio backdrop, every word, price
and your logo are set exactly, an art-direction model proposes three layouts and a judge picks the
best; the other two are returned as role: "shot" alternatives. Prices start under ten cents (reserving under
fifteen) and runs finish in under a minute. The style-reference image is not used on that tier. Photo edits keep
the creative director and reviewer on both tiers. Pass tier per request, or save a default for your organization
with PUT /api/v1/me/tier { "tier": "value" } (returned by GET /api/v1/me). GET /api/v1/tiers
lists the tiers and the models each may use; GET /api/v1/usecases returns prices per tier for
each use-case.
Listing generations
GET /api/v1/generations?limit=24&status=succeeded&cursor=<createdAt ISO> returns card-level fields
(no steps, plan or input; feedback included, see §5) newest first plus nextCursor; pass it back as cursor to page. status
filters server-side (queued | planning | running | reviewing | succeeded | failed). limit max 100.
2. The async contract
Rendering takes 1–5 minutes, so the API never keeps a long request open.
POST /api/v1/generate→ 202 immediately:{ "id": "0c9d5e4c-…", "status": "queued", "statusUrl": "https://…/api/v1/generations/0c9d5e4c-…", "estimatedSeconds": 150, "webhook": { "url": "https://your.app/hooks/b2bmedia" }, "generation": { … } }- Either poll
GET /api/v1/generations/{id}(aRetry-After: 15header is sent while the job is running; statuses:queued → planning → running → reviewing → succeeded | failed), or pass a webhook in the request and we POST the finished result to you. - Money: an upper estimate is reserved when the job is accepted and replaced by the actual
charge when it finishes (our provider cost × 1.5, in USD cents), never more than the reservation. Failed jobs
are free. A
402means the balance is below the reservation.
POST /api/v1/generate?wait=true keeps the connection open until the result is ready (≤ 5 min).
Use it only for quick manual tests.
Webhook
Request field:
"webhook": {
"url": "https://your.app/hooks/b2bmedia",
"metadata": { "stockNumber": "4471", "channel": "instagram" }
}
urlmust behttpsand publicly reachable.metadata(≤ 8 KB JSON) is stored with the job and echoed back verbatim so your handler needs no lookup.- Delivery:
POSTJSON, 10 s timeout, retried with backoff (6 attempts over ~1 h) until you answer 2xx. Deliveries are idempotent byX-B2BM-Event-Id.
Payload:
{
"id": "evt_<generationId>_succeeded",
"event": "generation.succeeded", // or generation.failed
"createdAt": "2026-09-23T01:12:00.000Z",
"generationId": "0c9d5e4c-…",
"metadata": { "stockNumber": "4471", "channel": "instagram" },
"generation": { "status": "succeeded", "outputs": [ … ], "review": { "status": "passed", … }, "chargedCents": 89, … }
}
Headers: X-B2BM-Event, X-B2BM-Event-Id, X-B2BM-Attempt,
X-B2BM-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>.
The signing secret is on the Account page (Webhooks section). Verify like this:
import crypto from "node:crypto";
export function verify(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1?.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Compute the HMAC over the raw request body, not a re-serialized object.
3. Photos
Inputs are image URLs. They must be reachable by our servers (public URL, or presigned URL valid for at least 30 minutes). If your images are private, upload them first:
POST /api/v1/uploads multipart/form-data, field "file" (JPG/PNG/WebP ≤ 25 MB)
→ { "url": "https://firebasestorage.googleapis.com/v0/b/…/o/uploads%2F…%2Fphoto.jpg?alt=media&token=…" }
Use the returned URL in the generate request.
When we cannot fetch one of the images, the generation fails before anything is rendered (nothing is charged):
errorCode: "reference_unreachable" when the link timed out, could not be reached or answered with an error status
(error names the link's host and the status, never the full link), "reference_invalid" when it is not an image or is
larger than 25 MB. Fix the link, or upload the file and use the returned URL, then send the request again.
All photos in one request must be of the same vehicle (exterior angles + interior shots of that car). Wide three-quarter angles with the whole car in frame give the best hero.
4. Use-cases
Common fields on the flyers:
| Field | Type | Notes |
|---|---|---|
exteriorPhotos | string[] 1–4 | required. Photos of the car's exterior. |
interiorPhotos | string[] 0–3 | optional; shown as thumbnails as-is |
carPose | as-shot | best-angle (default) | re-pose | best-angle: the director picks the most flattering photo you sent. re-pose synthesizes a new angle between your photos and requires ≥ 3 exterior photos (otherwise 400). |
make, model, year | string, string, integer | required |
trim, exteriorColor, mileage | string, string, integer (odometer reading: printed in miles, "38,210 mi", unless the currency is metric: km) | optional |
specs | string[] ≤ 5 | optional chips, e.g. "AWD · Drivetrain", "V8 · Engine" |
currency | USD EUR GBP TRY AED CAD AUD | defaults to the brand's currency |
headline, cta, footnote | string ≤ 28 / 40 / 120 | optional text; sensible defaults |
styleReference | string (image URL) | optional: a flyer whose layout language you like |
artDirection | auto (default) | classic | a language id | Quality tier only (the value tier composes a classic flyer). auto: the creative director reads this car's buyer, orders three visual languages (film still, poster, cover or print) and renders the first with your exact facts; a failed review retries it or moves to the next language; you get one piece. Revise with the instruction Try another direction to render the next language from the same plan (one render each; after three, the director plans anew). A language id (neo-noir-still, woodblock-print, blockbuster-one-sheet, swiss-typographic, auto-monthly-cover, …; see GET /api/v1/usecases for the list) gives that language. classic: the dealership flyer system. |
format | 2:3 (default) 4:5 1:1 9:16 | 2:3 ≈ 1696×2528 px |
Brand selection on every generate request (top level, next to input):
| Field | Meaning |
|---|---|
brandId omitted | the organization's default brand |
"brandId": "<id>" | a specific saved brand (see section 4.0) |
"brandId": "none" | unbranded piece (no logo, no contacts) |
"brand": { … } | inline overrides merged on top of the selected brand, e.g. { "phone": "(512) 555-0142", "accentColor": "#d62828" }. API-only customers can pass a full brand this way without saving one. |
4.0 Brands — /api/v1/brands
A brand = dealership name, logo, phone, email, website, address, tagline, primary and accent colors, currency. Organizations can save several (one per rooftop); one is the default.
# Upload the logo first (PNG with transparency works best) → use the returned url as logoUrl
curl -X POST "$BASE/api/v1/uploads" -H "Authorization: Bearer $API_KEY" -F "file=@logo.png"
# Create a brand (the first brand created becomes the default automatically)
curl -X POST "$BASE/api/v1/brands" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Northside Auto Group",
"logoUrl": "https://firebasestorage.googleapis.com/v0/b/…/o/uploads%2F…%2Flogo.png?alt=media&token=…",
"phone": "(512) 555-0142",
"email": "sales@northsideauto.example",
"website": "northsideauto.example",
"address": "4100 N Lamar Blvd, Austin, TX 78756",
"tagline": "Good cars. Straight talk.",
"primaryColor": "#0f172a",
"accentColor": "#d62828",
"currency": "USD"
}'
# → 201 { "brand": { "id": "Dnzj4Qdg2koJNMveLa36", … } }
# List brands and see which one is the default
curl "$BASE/api/v1/brands" -H "Authorization: Bearer $API_KEY"
# → { "brands": [ … ], "defaultBrandId": "Dnzj4Qdg2koJNMveLa36" }
# Dealers' own brands (written by PUT /api/v1/dealers/external/{id}, §7a) are left out; add ?include=dealer for them
curl "$BASE/api/v1/brands?include=dealer" -H "Authorization: Bearer $API_KEY"
# → { "brands": [ …, { "id": "dlr_8sK2…", "source": "dealer", … } ], … }
# Update fields (any subset)
curl -X PATCH "$BASE/api/v1/brands/{brandId}" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "phone": "281-555-0142", "accentColor": "#c1121f" }'
# Make a brand the default
curl -X PATCH "$BASE/api/v1/brands/{brandId}" -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "default": true }'
# Delete (existing results keep their branding)
curl -X DELETE "$BASE/api/v1/brands/{brandId}" -H "Authorization: Bearer $API_KEY"
Field rules: name ≤ 80 chars; colors are 6-digit hex; currency is a 3-letter ISO code;
logoUrl must be a URL we can fetch (use /api/v1/uploads); every field except name is optional.
4.1 New arrival flyer — car-dealership/new-arrival-flyer (quality from $0.72 art-directed, reserves $1.75 · value from $0.08, reserves $0.12)
curl -X POST "$BASE/api/v1/generate" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{
"usecase": "car-dealership/new-arrival-flyer",
"brandId": "Dnzj4Qdg2koJNMveLa36",
"webhook": { "url": "https://your.app/hooks/b2bmedia", "metadata": { "stockNumber": "4471", "kind": "new-arrival" } },
"input": {
"exteriorPhotos": ["https://cdn.dealer.com/4471/front34.jpg", "https://cdn.dealer.com/4471/rear34.jpg", "https://cdn.dealer.com/4471/side.jpg"],
"interiorPhotos": ["https://cdn.dealer.com/4471/dash.jpg"],
"carPose": "best-angle",
"make": "Chevrolet", "model": "Silverado 1500", "year": 2017, "trim": "Work Truck",
"exteriorColor": "Summit White", "mileage": 128000,
"specs": ["2WD · Drivetrain", "V8 · Engine", "Ext Cab · Cab style", "Clean · Condition"],
"price": 5499, "currency": "USD",
"headline": "Just arrived",
"cta": "Call now — this one moves fast!",
"footnote": "Plus tax, title and fees.",
"format": "2:3"
}
}'
price is optional; omit it to publish without a price.
4.2 Price-drop flyer — car-dealership/price-drop-flyer (quality from $0.72 art-directed, reserves $1.75 · value from $0.08, reserves $0.12)
Same fields as above, but oldPrice and newPrice are required (the savings line is computed).
curl -X POST "$BASE/api/v1/generate" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{
"usecase": "car-dealership/price-drop-flyer",
"brandId": "Dnzj4Qdg2koJNMveLa36",
"webhook": { "url": "https://your.app/hooks/b2bmedia", "metadata": { "stockNumber": "3310", "kind": "price-drop" } },
"input": {
"exteriorPhotos": ["https://cdn.dealer.com/3310/front34.jpg", "https://cdn.dealer.com/3310/rear.jpg"],
"carPose": "best-angle",
"make": "BMW", "model": "3 Series", "year": 2019, "trim": "318d",
"exteriorColor": "Portimao Blue", "mileage": 42000,
"specs": ["RWD · Drivetrain", "Diesel · Engine", "Automatic · Transmission"],
"oldPrice": 27450, "newPrice": 24990, "currency": "USD",
"footnote": "Plus tax and fees. Offer ends Sunday.",
"format": "4:5"
}
}'
4.3 Inventory showcase — car-dealership/inventory-collage (quality from $0.72 art-directed, reserves $1.76 · value from $0.09, reserves $0.13)
Two to four cars in one designed piece. Arrays are aligned by index: photos[i] ↔ cars[i] ↔ prices[i].
| Field | Type | Notes |
|---|---|---|
photos | string[] 2–4 | required, one photo per car, display order |
cars | string[] 2–4 | required, one line per car, e.g. "2018 Land Rover Range Rover Velar" |
prices | string[] ≤ 4 | optional, pre-formatted, e.g. "$21,999" |
title | string ≤ 40 | default Hand-picked for you |
subtitle | string ≤ 80 | optional |
cta | string ≤ 40 | default Ask about these |
artDirection | auto (default) | classic | a language id | Quality tier only (the value tier composes a classic showcase). auto: the creative director reads the lot (what the cars have in common, who buys them) and the measured photo angles, orders three visual languages that hold several cars (pop-art-panels, blockbuster-one-sheet, expedition, luxury-editorial, auto-monthly-cover, …; row and convoy languages only when the photos agree in view and facing) and renders the first with every name and price beside its car; one piece. Try another direction (revision) renders the next language. A language id gives that language. classic: the dealership showcase. |
styleReference, format | as above |
curl -X POST "$BASE/api/v1/generate" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{
"usecase": "car-dealership/inventory-collage",
"brandId": "Dnzj4Qdg2koJNMveLa36",
"webhook": { "url": "https://your.app/hooks/b2bmedia", "metadata": { "campaign": "week-39" } },
"input": {
"photos": ["https://cdn.dealer.com/5120/front34.jpg", "https://cdn.dealer.com/5188/front34.jpg", "https://cdn.dealer.com/5201/front34.jpg"],
"cars": ["2018 Land Rover Range Rover Velar", "2017 Land Rover Range Rover Sport", "2019 Land Rover Range Rover Sport"],
"prices": ["$21,999", "$18,499", "$19,999"],
"title": "Hand-picked for you",
"subtitle": "Quality vehicles. Exceptional value. Drive with confidence.",
"cta": "Ask about these",
"format": "2:3"
}
}'
4.4 Vehicle hero shot — car-dealership/hero-shot (quality from $0.31, reserves $0.81 · value from $0.25, reserves $0.50)
One lot photo becomes a listing cover image of the same car, relit and staged. Fields: imageUrl (required), make, model, year (required), exteriorColor, aspectRatio (16:9 default · 4:3 · 1:1 · 4:5 · 9:16), brandTone.
| Field | Type | Notes |
|---|---|---|
scene | auto (default) | showroom | studio-dark | urban-night | coastal-road | mountain-pass | dealership-lot-clean | Value tier: one scene, one image. |
artDirection | auto (default) | classic | a scene language id | Quality tier: auto reads the car's buyer, orders three scene languages (neo-noir-still, lonely-americana, expedition, luxury-editorial, symmetrical-storybook, blockbuster-one-sheet, auto-monthly-cover) and stages the car in the first as a text-free photograph; one image. Try another direction (revision) renders the next scene; a language id gives that world; classic picks one scene by segment. |
4.5 Service special ad — car-dealership/service-offer-ad and Sold / delivery post — car-dealership/sold-post (quality from $0.71, reserves $1.74 · value from $0.07)
Text-led pieces without a car cutout. Service offer fields: offer (required, ≤ 40), price, details (≤ 4 lines), validUntil, cta, footnote, photo (your service photo, used as-is), styleReference, format (1:1 default · 4:5 · 9:16 · 16:9). Sold post fields: photo (required, the delivery photo, as shot), vehicle (required), firstName, headline (default Sold!), message, cta, format (4:5 default).
| Field | Type | Notes |
|---|---|---|
artDirection (service offer) | auto (default) | classic | a language id | Quality tier only. auto: the creative director reads the offer and sets it as a poster, cover or print (swiss-typographic, pulp-revenge-poster, pop-art-panels, newsweekly-cover, retro-travel-poster, lonely-americana, cubist-collage); your service photo is placed as-is, otherwise the subject is one workshop object (never an invented car or people); one piece, Try another direction renders the next language. classic: the service ad system. |
artDirection (sold post) | auto (default) | classic | a language id | Quality tier only. auto: the creative director frames the delivery photo (as shot, nothing over faces) as a cover, poster or print (newsweekly-cover, auto-monthly-cover, pop-art-panels, pulp-revenge-poster, swiss-typographic, cubist-collage); one piece, Try another direction renders the next language. classic: the branded Sold frame. |
4.6 Vehicle film — car-dealership/vehicle-film (motion scene: from $1.48, reserves $3.13 with four photos · scene language: from $3.83, reserves $8.27 · same on both tiers)
A short film of one car (mediaType: "video"). Fields: exteriorPhotos (required, 1–4), make, model, year (required), trim, exteriorColor, occasion (listing default · new-arrival · price-drop), price, oldPrice (price drop), currency, mode (scene default · language), scene (auto default · desert-trail · forest-stream · coast-highway · mountain-pass), artDirection (language mode: auto default or a film language: expedition, neo-noir-still, lonely-americana, luxury-editorial, symmetrical-storybook, blockbuster-one-sheet, auto-monthly-cover), format (4:5 default · 9:16 · 16:9).
Motion scene (default): your car replaces the vehicle in a real driving clip (desert trail, mountain pass, coast highway…), then an end card with the name, price and your contacts. Send every exterior angle you have: a front three-quarter first (front-left, the driver's side, unlocks the most scenes), then a rear three-quarter from each side and a rear. The film never shows a side of your car you did not photograph and never puts your car on another kind of vehicle. A car's left and right are different sides: a front-left photo shows the front and the left side only, never the right side (an unphotographed side of a used car may hide damage, so it is never painted from the other side):
- Views are measured. We read which sides of the car each photo shows: its front, left side, right side or rear (a three-quarter photo shows its end and the side it is taken from; left and right are the car's own sides). Nothing is assumed: if that reading fails twice, the generation fails with a provider error and nothing is charged.
- Clips are chosen by coverage and body. A clip is used only when you photographed every side it shows and its vehicle has your car's body type (pickup, SUV, sedan or coupé). Among those, the one that shows the most of your photographed car is used. With a single front-left three-quarter photo, pickups, sedans and coupés get a film today (one clip each); with a single front-right photo only pickups do; an SUV needs a rear-right three-quarter too, because our SUV clip shows the car's right side and rear.
- Which photos reach the video model: only the photos that show a side or end the clip shows, sent as clean cutouts on grey when the cutout succeeds, otherwise as sent. For a clip that shows the front and the left side, that is front, front three-quarter (either side, for the front), left profile and rear-left photos, not a straight rear, a right profile or a rear-right. For a clip that shows the rear and both sides, it is every photo that shows the rear or a side. At most four go (one main image and three references), preferring photos that add a side the clip shows. If the model refuses that many images, references are dropped only while the remaining photos still show every side of the clip; otherwise the generation fails. The reviewer sees all your exterior photos.
- When no clip fits the generation fails with
errorCode: "coverage"before anything is rendered (nothing is charged), anderrornames the views the scenes show and the photo to add, for example:Your photos show the car's front and left side, but every driving scene for this car also shows its right side and rear. Add a rear-right three-quarter photo (rear and passenger side) and try again: …. A car whose body type none of our scenes carries yet (a van, for example) fails with a plain message suggesting the scene language mode. If you chose ascenethat cannot carry your car with these photos, another scene that can is used andreview.issuesstarts withscene: you chose "…", but …; "…" was used instead. - Only a checked clip is delivered. The clip must pass review and the orientation check (the edited car shows the same end as the stock vehicle at the same moment, in at least two of the three checked frames). A review call that fails is retried once. A clip the reviewer or the orientation check could not read, or one with fewer than two frames where the end can be compared, fails the generation (provider error, nothing charged) and is never re-rendered blind.
Try another directionproduces the next scene and never renders a scene or a stock clip this chain already showed. When your photos allow no other clip, it fails with a plain message naming the photo that would unlock one, and nothing is charged.
Outputs: the film (role: final, mp4) plus the edited clip (role: shot). Typical run 4–8 minutes.
Scene language: the car is staged as a start frame in a visual language (the quality photo-edit model gets your first photo as a cutout on grey when the cutout succeeds, otherwise as sent, and is reviewed for identity, colour and light), three camera moves from the language's grammar are animated and reviewed on frames, and the film is cut by code with the language's grade and typography. Only clips that pass review are cut into the film. A move that would reveal a side the start frame does not show is re-rendered once holding the viewpoint; a clip the reviewer could not read is not re-rendered. With fewer than two passing clips the generation fails and nothing is charged. Outputs: the film plus the start frame (still) and the clips (clip <move>; clips left out are labelled (dropped)). Typical run 5–7 minutes.
Every clip is reviewed on frames from its start, middle and end; Try another direction produces the next scene or language.
4.7 Add your branding — car-dealership/brand-stamp (value from $0.01 · quality from $0.21, reserves $0.42)
Puts the brand's logo, name and contacts on any photo without altering the photo: the layer is drawn by code, so every
letter is exact. Fields: photo (required), prominence (subtle default: a logo chip in the quietest corner ·
visible: a brand bar across the bottom with logo, name, phone and website), removeExisting (quality tier, default
true: an edit model first removes dealer stickers, plate-frame lettering, banners and watermarks already on the photo;
reviewed, one retry, the best attempt delivered, the original photo kept only when every attempt is broken; the value
tier stamps over existing branding). Needs a brand (brandId or the default). Outputs: the branded image (final)
and the cleaned photo (shot, label cleaned) when existing branding was removed. The compose step's
cleanedScore and a guard clean-fallback step tell you which one was stamped.
4.8 Branding on the hero shot
car-dealership/hero-shot takes branding (none default · subtle · visible): the same brand layer is drawn on
the delivered image after the shot; the unbranded image is kept as a shot output labelled unbranded.
4.9 Virtual studio set — car-dealership/studio-set (capture photos: from $0.05 per photo, reserves about $0.06; photos from your own URLs: from about $0.11 per photo, reserves about $0.22)
Every photo of ONE vehicle, returned as if shot in one studio, the way dealer portals expect it. Send photos (1–24
URLs, in the order you want them back), wallTone (grey default · warm · white · charcoal), signImage
(optional: an image hung on the back wall, top-left; without it the brand logo is used, and without a logo the
dealership name is set as mounted letters on the wall), cornerBadge (optional: an image placed bottom-right on every
exterior, e.g. a "great deal" or model badge), numberPlate (logo default: a plate with your brand's logo or name
drawn in its place, blurred when the brand has neither · blur · keep), windowTint (off default · light ·
dark) and aspectRatio (4:3 default · 3:2 · 16:9 · 1:1). One request per vehicle; for a whole lot, send one
request per stock number with your own id in webhook.metadata and consume the results as they land (see §2).
What happens: a small vision model sorts the photos (exterior · interior · detail); every exterior is cut out and kept
EXACTLY where and how large
the photographer shot it, frame-edge crops included; everything around it becomes a studio with a back wall and a
lit floor, a floor shadow measured from professional dealer imagery, and your sign on the wall. No image model
touches the car: the pixels of the car are the dealer's. Interiors and details come back as shot and are listed in
review.issues as photo[i]: delivered as shot (reason).
How the car is cut out depends on where the photos come from (Nov 8 2026):
- Photos taken with a capture session (§7a: sent one at a time with §7d, linked with
sessionId, or every photo a capture upload's URL): background removal and a segmentation model on the full photo, finished at full resolution by code, plus the phone's own outline, and no judge (below). About 10–20 seconds a photo. - Photos from your own URLs: background removal, HD isolation with a segmentation model and a matting pass, and
every staged photo is reviewed by a vision model (cutout complete and untouched, tyres on the floor, plate,
nothing else added), two reads: a render is not delivered only when both reads find a blocker (a hole or missing
part in the car, the car visibly floating, a readable plate); that photo comes back as shot with the reasons in
review.issues;review.statusispassedwhen every staged render passed,review.scorethe lowest delivered score. About 2–3 minutes a photo. These sets move to the capture path once it has been reviewed on more cars; the price you are quoted is always the path that will run.
Outputs: one final per input photo, in input order, labelled photo[i] (staged) or photo[i] · as shot, at the
photo's own size. On the capture path each staged photo is followed by a shot labelled photo[i] · alpha: a PNG at
the photo's size whose alpha channel is the delivered outline of the car (use it to place the car on your own
backgrounds). On the capture path no judge reviews the set (since Nov 7 2026): the car's pixels are the dealer's,
so what can go wrong is measured by code.
The car's outline comes from two or three independent opinions (background removal, a segmentation model, and on a
capture photo the phone's outline); where they disagree a majority decides, a disagreement no majority settles is
re-matted on that region only, and a region still unclear afterwards sends the photo back as shot with the region
named (photo[3]: delivered as shot (outline unclear at the upper right of the car (x 0.87–0.91, …))). The plate is
found and treated by code; a front or rear view on which no plate was found carries photo[i]: note: plate: none found on an end view; check the photo. review.status is composed, review.score null, and review.issues
lists the measured lines: as-shot photos with their reason, then note: lines (a disagreement the vote settled, the
plate gate, a car touching the frame edge). Revisions are not offered;
run the set again with another wall tone. Quality tier only. A set made from a capture session's photos (§7a) reuses
what the session's pre-flight already measured on them (photo kind, the car's outline, the plate): those steps are
not run again, and the set is charged as it would be without them (pre-flight itself stays free).
Photos taken with a capture session can also be sent one at a time, each as its own generation, as soon as each is accepted (§7d): the results arrive while the dealer is still walking around the car. This whole-set request keeps working unchanged.
4.10 Walk-around video — car-dealership/walkaround-video (from $0.23 with two exterior photos, about $0.19 more per photo: a six-photo walk from $0.98, reserves $2.15 · quality tier only)
A calm walk-around of ONE parked vehicle for its listing page (mediaType: "video": an mp4 at the photos' own 4:3,
960×720, about 3.4 s of camera movement between neighbouring photos, each interior 3 s, an end card with your
dealership). Fields: exteriorPhotos (required, 2–12, in the order you walked round the car), interiorPhotos
(0–8, optional, shown after the walk with a slow push). Your brand as usual (brandId).
What happens: the camera moves between neighbouring photos only, and the frames between two photos are made from
those two photos, so the order matters and no side you did not photograph is ever shown. Before anything is
rendered, every exterior photo is placed round the car (which end it shows by a vision model, which side by geometry)
and neighbours must share a part of the car (its front, a side or its rear): the four corners in order pass; a front
corner, that side, the rear corner on that side, the rear, the other rear corner, the other side passes; a front corner
followed by the straight rear does not. A set that skips a side, or a photo that is not a whole-car exterior (an
interior or a close-up in the exterior field), fails with errorCode: "coverage" before any render and nothing is
charged; error names the photo to add and between which two photos, or the photo to move or replace. Six to eight
exteriors make a full walk; each photo appears as you shot it (lettering on plates and signs may soften while the
camera moves).
Outputs: the video (role: final) plus each generated clip between two photos (role: shot). review.status is
unreviewed: there is no vision reviewer; the walk's smoothness and each photo's match at its moment are measured by
code and come back as note: lines in review.issues, and a walk with a missing photo or a hard cut is refused and
not charged. Typical run 3–5 minutes for six exteriors. Revisions are not offered; send the set again. The price
grows with the number of exterior photos (one generated clip per neighbouring pair).
4.11 Restaurants & cafés — art direction on every piece (Oct 15 2026)
The restaurant use-cases (restaurant/dish-hero, restaurant/menu-post, restaurant/offer-ad,
restaurant/specials-board) run at the quality tier only and carry an artDirection field: auto (default, the
creative director reads the guest and the motive — craving, ritual, occasion, health, value, discovery — and picks
the visual language; one piece; Try another direction renders the next), classic (the plain branded piece) or a
language id. Languages that hold food: chiaroscuro-still-life, gourmet-cover, bistro-poster,
scandi-flat-lay, market-riso-poster, plus neo-noir-still, lonely-americana, swiss-typographic,
newsweekly-cover, pop-art-panels, cubist-collage, woodblock-print, luxury-editorial (and
retro-travel-poster, pulp-revenge-poster on text-led pieces). The dish is always the customer's photo (cut out
and placed as-is, or relit in place on the dish hero); a photo-less offer or board shows one ingredient or object
in the language's hand, never an invented dish. From $0.72 per designed piece, $0.31 for the dish hero.
4.12 Real estate — art direction, virtual staging, market update (Oct 15 2026)
real-estate/just-listed-post and real-estate/open-house-flyer carry artDirection (auto · classic · a language
id: estate-cover, open-house-postcard, blueprint-print, luxury-editorial, lonely-americana,
symmetrical-storybook, retro-travel-poster, newsweekly-cover, swiss-typographic, cubist-collage). The
property photos are placed whole and unaltered (MLS / NAR); the piece never relights, re-skies or re-furnishes them;
no people are ever added (Fair Housing). real-estate/virtual-staging (imageUrl, room, style: modern ·
scandinavian · transitional · mid-century · coastal · farmhouse, aspectRatio, notes) furnishes an empty room while
structure, floors, windows and the view stay exactly as photographed; the delivered image carries a code-drawn
"Virtually staged" label (only the labelled image is returned). real-estate/market-update-post (area, period,
stats as "label: value" lines, headline, note, cta, artDirection, format) sets the month's numbers as a
piece of print; every figure is read back. Sold / under-contract posts are the listing status post with status.
4.13 E-commerce — art direction, marketplace set, bundle, feature call-out (Oct 15 2026)
ecommerce/product-hero (default artDirection: classic = the marketplace backgrounds; a scene language stages the
product), ecommerce/lifestyle-scene and ecommerce/product-ad carry artDirection with the product languages
(catalogue-editorial, colour-block-studio, swiss-typographic, newsweekly-cover, pop-art-panels,
cubist-collage, woodblock-print, luxury-editorial, chiaroscuro-still-life, scandi-flat-lay,
market-riso-poster, neo-noir-still; annotated-diagram on the call-out). The product is always the customer's
cutout: shape, colours, materials, packaging, label text and quantity stay as photographed; no ratings, reviews,
badges or claims beyond the facts. New: ecommerce/marketplace-set (imageUrl, optional secondPhoto,
productName, productType, sizeHint, aspectRatio 1:1 · 4:5) returns five reviewed images — main-white
(the final), angle, detail, scale, lifestyle as shots; ecommerce/bundle-shot (productPhotos 2–5,
bundleName, sizeHints, artDirection, aspectRatio) stages the products together at real relative size;
ecommerce/feature-callout (productPhotos, productName, headline, features 3–6 lines, price, cta,
artDirection, format) joins each benefit line to the product with a leader line; every line is read back.
4.14 Retail — art direction, window poster, gift guide, shopping ad image (Oct 15 2026)
retail/new-arrival-post (1–4 product cutouts as a lookbook, all items whole, the first as hero) and
retail/sale-banner carry artDirection with the shop languages (window-display, catalogue-editorial,
colour-block-studio, scandi-flat-lay, bistro-poster, market-riso-poster, swiss-typographic,
newsweekly-cover, pop-art-panels, cubist-collage, woodblock-print, luxury-editorial,
chiaroscuro-still-life). New: retail/window-poster (headline, subline, hours, dates, optional photos,
cta, artDirection, format 2:3 · 3:4 · 1:1) for the storefront; retail/gift-guide (productPhotos 3–6,
names, prices in the same order, title, subtitle, cta, artDirection, format): every product beside its
own name and price, pairing read back; retail/shopping-ad-image (imageUrl, productName, aspectRatio,
notes): the pure-white catalogue image for Google Shopping and Meta feeds. Products are the customer's cutouts
(same item, colour, print, label, each once); no models, no invented discounts or urgency.
4.15 Home services — art direction, review card, truck hero, checklist (Oct 15 2026)
home-services/before-after-post (two job photos at equal size, labelled, in a panel language: pop-art-panels,
job-ticket, blueprint-print, swiss-typographic, cubist-collage, market-riso-poster) and
home-services/offer-ad (text-led; job-ticket, swiss-typographic, market-riso-poster, blueprint-print,
retro-travel-poster, pop-art-panels, newsweekly-cover) carry artDirection; job photos stay exactly as shot,
the phone number is the second-largest element, trust lines only as given. New: home-services/review-card
(quote, reviewer, stars 1–5 only if the review has them, source, service, optional photo, cta,
artDirection, format): the quote word for word, exactly the given number of stars; home-services/fleet-hero
(imageUrl, subject truck · team · both, artDirection scene languages, aspectRatio, notes): the truck's
lettering and every face stay as shot; home-services/seasonal-checklist (title, items 3–8, note, cta,
artDirection, format): a tick-box card, every line exactly as written.
4.16 Salons & spas, gyms & studios — art direction and new pieces (Oct 15 2026)
Beauty: beauty/service-promo-post (photos as-is, hero + insets) and beauty/price-menu carry artDirection with
the salon languages (salon-editorial, barber-sign, newsweekly-cover, luxury-editorial, pop-art-panels,
scandi-flat-lay, chiaroscuro-still-life, neo-noir-still, swiss-typographic, market-riso-poster,
cubist-collage; bistro-poster on text-led pieces). Work photos are never retouched; no result or product claims.
New: beauty/before-after-post (beforePhoto, afterPhoto, service, detail, price, consent must be true,
cta, artDirection, format); beauty/gift-card-promo (occasion, headline, amounts, bonus, validUntil,
howToBuy, optional photo, cta, artDirection, format 9:16 · 4:5 · 1:1 · 16:9); beauty/booking-reminder
(title, slots 1–8, note, optional photo, cta, artDirection, format 9:16 · 4:5).
Fitness: fitness/offer-ad and fitness/class-schedule-post carry artDirection with the gym languages
(gym-poster, newsweekly-cover, pop-art-panels, blockbuster-one-sheet, neo-noir-still, swiss-typographic,
market-riso-poster, cubist-collage, scandi-flat-lay, luxury-editorial); photos as-is, no transformation
claims. New: fitness/challenge-announcement (name, dates, detail, price, spots, optional photo, cta,
artDirection, format) and fitness/trainer-spotlight (photo, name, specialty, quote verbatim, classes,
cta, artDirection, format).
4.17 Hotels & rentals, clinics — art direction and new pieces (Oct 15 2026)
Hospitality: hospitality/offer-post carries artDirection with the property languages (grand-hotel-label,
retro-travel-poster, estate-cover, luxury-editorial, open-house-postcard, lonely-americana,
symmetrical-storybook, newsweekly-cover, swiss-typographic, cubist-collage); property photos stay as-is, no
invented stars, awards or amenities. New: hospitality/ota-photo-set (photos 2–6, look bright-airy ·
warm-evening · clean-neutral, aspectRatio): one reviewed edit per photo to one consistent look, the first photo is
the final and the rest are shots set[i]; hospitality/amenity-collage (photos 3–6, captions in the same order,
title, subtitle, cta, artDirection, format); hospitality/package-flyer (name, price, inclusions 1–6,
dates, terms, optional photos, cta, artDirection, format 2:3 · 3:4 · 16:9 · 4:5).
Clinics: clinics/new-patient-offer-ad and clinics/service-spotlight-post carry artDirection with the clinic
languages (calm-clinic, salon-editorial, newsweekly-cover, estate-cover, symmetrical-storybook,
swiss-typographic, catalogue-editorial, luxury-editorial); only the clinic's facts, fine print complete, no
outcomes or invented clinical scenes. New: clinics/team-intro (photo, names 1–8 in order, roles, headline,
note, cta, artDirection, format); clinics/office-tour-collage (photos 3–6, captions, title, note,
cta, artDirection, format); clinics/appointment-reminder (headline, detail, deadline, hours, optional
photo, cta, artDirection, format 9:16 · 4:5 · 16:9 · 1:1).
4.18 Event venues, auto services, schools, professional services, pet services (Oct 15 2026)
Five industries went live with two art-directed pieces each (quality tier only; artDirection = auto · classic ·
a language id, thumbnails under /showcase/art-<industry>/). Photos are always placed as-is; nothing is retouched.
events-venues/venue-hero(imageUrl,enhancementbright-airy · golden-hour · twilight · candlelit,aspectRatio,notes): the space and its set-up untouched, only light and sky change.events-venues/event-promo(title,date,time,detail,price, optionalphoto,cta,artDirection,format4:5 · 1:1 · 9:16 · 2:3): date and time are checked verbatim; no invented capacity or awards.auto-services/before-after-post(beforePhoto,afterPhoto,service,vehicle,detail,price,cta,artDirectionpanel languages only,format): equal-size pair, labels checked, the customer's car as shot.auto-services/service-offer-ad(headline,offer,detail,validUntil,finePrint, optionalphoto,cta,artDirection,format): the phone number is the second-largest element; no stock cars.education/enrollment-ad(program,ages,dates,offer,price,detail, optionalphoto,cta,artDirection,format) andeducation/class-promo-post(className,schedule,ages,price,detail, optionalphoto,cta,artDirection,format): no grades, scores or outcomes beyond the given facts.professional-services/quote-card(text8–220 chars set word for word,attribution,topic, optional portraitphoto,cta,artDirection,format) andprofessional-services/consult-offer-ad(headline,offer,service,detail,deadline,finePrint, optionalphoto,cta,artDirection,format): no case results, figures or credentials invented.pet-services/groom-result-post(photo,petName,service,detail,price,cta,artDirection,format): the pet's face never covered, the name spelled as given.pet-services/offer-ad(headline,offer,detail,validUntil,finePrint, optionalphoto,cta,artDirection,format): no health claims.
4.19 Personal use-cases through the API (Oct 15 2026)
Personal (audience: personal) use-cases need the personal-use acknowledgement first. Browser users accept it per
account; API keys keep the acknowledgement on the organization: call PUT /api/v1/me/consent { "personal": true }
once with the key (an owner's acceptance for the organization), then generate. Pieces that carry a person's photo run
Face Lock: every face in the photo is matched to the render by code (identity and pose) and the photo's own face
pixels are kept; a generation whose faces cannot be kept fails with a plain message and is not charged. The result
is labelled "AI-edited image".
Keepsakes (Oct 16 2026): text-led personal pieces with artDirection (auto · classic · a language id), the
photo placed whole and every face kept by Face Lock. Live: kids/first-day-sign (photo, name, grade, year,
favourites, format 4:5 · 1:1 · 9:16), sports/game-day-invite (photo?, game, date, time, place,
colors, bring, format), sports/marathon-finisher (photo, name, race, distance, time, date,
format 2:3 · 4:5 · 1:1 · 9:16), flyers/lost-pet-flyer (photo, name, description, lastSeen, phone,
reward, format 2:3 · 4:5 · 1:1). holidays/holiday-card and flyers/birthday-invite take artDirection too. Wave 2 (Oct 16): flyers/graduation-announcement,
couples/anniversary-card, couples/wedding-thank-you, couples/proposal-story, flyers/baby-shower-invite,
flyers/block-party, pets/pet-birthday, portraits/magazine-cover, memes/reaction-card, memes/trading-card,
sports/mvp-card (stats one per line), kids/growth-collage (photos 2–6 youngest first, ages in the same
order), and two room previews: home/wall-color (imageUrl, color, finish) and home/holiday-decor
(imageUrl, holiday, mood). Field lists: GET /api/v1/catalog?audience=personal and the studio form.
A use-case that is not yet good enough is not offered at all (it answers usecase_not_found); new ones appear in
GET /api/v1/catalog?audience=personal as soon as they are live.
4.9 Build your own UI — GET /api/v1/industries/{industry}/capabilities
One call returns everything a front end needs to offer an industry's catalogue in your own product: the live
use-cases with an ordered, renderable fields list (type, control, options with thumbnail URLs, tier scoping, which
field the intake reads), the tiers and prices, channel labels, the generation lifecycle (statuses, output roles and
labels, revision contract incl. the Try another direction instruction) and the endpoints. Poll it (daily is fine):
new use-cases and fields appear here first; contractVersion changes when the shape of this document changes, each
use-case carries its own version.
curl "$BASE/api/v1/industries/car-dealership/capabilities" -H "Authorization: Bearer $API_KEY"
Field spec (fields[]): key, title, description, type (string · number · integer · boolean · image ·
images · lines · enum), required, default, control (cards · segmented · select · text ·
textarea · number · toggle · image · images · lines), options[] (value, label, hint, imageUrl,
optional requires), tier + offTier (a field that exists only on one tier; send offTier on the other, or omit
it: the server resets it), constraints (min, max, minLength, maxLength, minItems, maxItems), intake
(send this field's photo to POST /api/v1/analyze to pre-fill the rest). Render images fields as multi-upload
(POST /api/v1/uploads returns URLs), lines as one-per-line text, cards as illustrated choices.
5. Reading the result
GET /api/v1/generations/{id} (or the webhook's generation object):
{
"id": "…", "status": "succeeded", "usecaseId": "car-dealership/price-drop-flyer",
"tier": "value",
"modelId": "zivdeo/layout-engine",
"outputs": [
{ "role": "final", "label": "final", "url": "https://firebasestorage.googleapis.com/…?alt=media&token=…", "width": 1200, "height": 1500, "contentType": "image/jpeg" },
{ "role": "shot", "label": "alternative hero-right", "url": "https://firebasestorage.googleapis.com/…", "width": 1200, "height": 1500, "contentType": "image/jpeg" },
{ "role": "shot", "label": "alternative poster", "url": "https://firebasestorage.googleapis.com/…", "width": 1200, "height": 1500, "contentType": "image/jpeg" }
],
"review": { "status": "composed", "score": null, "issues": [], "attempts": 3 },
"chargedCents": 7,
"retryCount": 0,
"error": null,
"parentId": null,
"feedback": null
}
- Use the output with
role: "final"(alwaysoutputs[0]). URLs are long-lived tokenized links; download and store a copy on your side. setId(Nov 7 2026): a studio photo sent one at a time (§7d) is its own generation ofcar-dealership/studio-setwith one photo;setId(ss_…) names the set it belongs to,captureUploadId/captureSessionIdthe photo it made, andinput.anchorwhat the set's first photo measured (set by us; never send it).nullon every other generation. Read the whole set withGET /api/v1/vehicles/{id}/studio-sets/{setId}instead of collecting the generations yourself. Each photo has its own hold, charge, webhook delivery andreview.- On the value tier, designed pieces also return the layouts our judge did not pick as
role: "shot"with alabelstarting withalternative; show them to your user or ignore them. On the quality tieroutputsis usually a singlefinal. modelIdnames the Zivdeo model that produced the piece (zivdeo/…);zivdeo/layout-enginemeans the piece was composed by our layout engine rather than rendered by an image model.status: "failed"→errorexplains why; nothing was charged.errorCodesays what to do:face_lock(use a clearer photo of the face),face_check_unavailable(try again in a few minutes),quality(try again or another photo),coverage(vehicle film: add the photo views named inerror; walk-around video: two neighbouring exterior photos show no common part of the car (its front, a side or its rear), so the walk would pass a side you did not photograph, or one photo is not a whole-car exterior;errornames the photo to add and between which two photos, or the photo to move or replace),nullotherwise.- When an AI service behind the piece fails,
erroris a fixed message anderrorCodeone ofprovider_unavailable(did not respond in time: retry),provider_quota(over capacity: retry in a few minutes),provider_auth(refused our request: retry later),content_policy(a safety filter declined the photos or wording: change them),provider_error(anything else: retry once).
The review: decide whether to publish without a human
Every piece is checked before delivery. Only blockers trigger a new render: the vehicle (or product, dish, room,
person) changed or broken, an identifier wrong or missing (year, make, model, price, mileage, phone, website,
address, business name), an invented claim a buyer could act on (certification, history report, warranty,
financing), a compliance rule, or a gross absurdity. Code measures what a reviewer misses (paint colour,
orientation, subject size, brightness, faces). Logo nuances, reworded copy lines, scene text and taste are recorded
as notes and never trigger a render. The render budget is limited.
review (set when status is succeeded, also in the webhook) tells you how the delivered piece came out:
review.status | Meaning | Suggested handling |
|---|---|---|
passed | The delivered piece passed the reviewer and every code check. | Publish. |
best_effort | No render passed within the budget; the best-scoring one was delivered. issues lists its faults. | Hold for a human, or ask for a revision / Try another direction. |
composed | Drawn by code from your photos and facts (value-tier layouts, the virtual studio set): text and the vehicle are exact by construction, so there is no pass/fail review and score is null. issues lists what code measured: a text/vehicle overlap the layout guards could not solve; for the studio set, photos delivered as shot with their reason and note: lines (outline disagreements settled, a plate not found on an end view). | Publish when issues is empty; for the studio set, publish the staged photos and check the ones named in issues. |
unreviewed | This kind of piece has no review step, or the reviewer could not judge it. | Treat as best_effort. |
score: 0–10 for the delivered piece (7–10 fine to post, 5.5 or below a blocker remained); for a film, the lowest of its parts.nullwhen composed or unreviewed.issues: plain-English findings, blockers first ("FACTS: text: expected 555-0100, saw 555-0108", kindsSUBJECT,FACTS,COMPLIANCE,NONSENSE), then advisory lines startingnote:. Apassedpiece carries notes only.attempts: renders checked for this generation (for composed pieces, layouts compared).passedwithattempts: 3means two renders were rejected before this one. You pay for the renders that ran either way.reviewisnullwhile the job runs, on failed jobs and on jobs finished before Oct 19 2026.
Tell us whether the piece was used — POST /api/v1/generations/{id}/feedback
Your users see our pieces; we do not. Two signals from your screens tell us which pieces work, and they are what we tune the engine on:
- 👍 / 👎 under each delivered piece, with reason chips after a 👎 ("Would you post this?").
used: truewhen the dealer publishes the piece (posts it, puts it on the listing, prints it): send it from your existing publish action, no extra button needed.used: falsewhen they decide not to use it.
# The dealer tapped 👎 and "Not my car"
curl -X POST "$BASE/api/v1/generations/{id}/feedback" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "rating": -1, "reasons": ["car-changed"], "note": "rear wheels are different" }'
# → 200 { "feedback": { "rating": -1, "reasons": ["car-changed"], "note": "rear wheels are different", "used": null, "at": "2026-10-27T21:31:17.341Z" } }
# Later the dealer posted it
curl -X POST "$BASE/api/v1/generations/{id}/feedback" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "used": true }'
# → 200 { "feedback": { "rating": -1, "reasons": ["car-changed"], "note": "rear wheels are different", "used": true, "at": "…" } }
Body: at least one of these fields; every call is a partial update.
| Field | Meaning |
|---|---|
rating | 1 (👍) or -1 (👎); "up" / "down" are accepted too |
reasons | Reason keys from the table below, up to all 9 at once (duplicates are dropped) |
note | Free text, up to 280 characters |
used | true the piece was published, false the dealer decided not to use it |
| Reason | Suggested chip label | When |
|---|---|---|
car-changed | Not my car | The car in the piece is not the dealer's car: wheels, grille, colour, trim or angle differ |
facts | Something is wrong | A price, name, phone or other fact is wrong or missing |
pasted | Looks pasted on | The car does not sit in the scene |
edges | Rough edges | Ragged or haloed outline |
text-small | Text too small | Hard to read on a phone |
crowded | Too crowded | Too much on the piece |
wrong-vibe | Wrong vibe | Not the dealer's style |
love-it | Love it | With a 👍 |
other | Other | Anything else (add a note) |
- Merge rule: a field you omit keeps its stored value, so
{ "used": true }never erases a rating and a rating never erasesused.reasonsandnotereplace the stored value when sent ([]/nullclear them). The stored reasons and note are cleared in one case only: a stored rating flips to the other value (👍 → 👎 or 👎 → 👍); the same call's ownreasons/noteare still saved. A first rating keeps reasons and a note sent before it, so{ "reasons": ["car-changed"] }followed by{ "rating": -1 }stores both; repeating the same rating changes nothing else. Send as often as the user changes their mind. - Read it back as
feedbackonGET /api/v1/generations/{id}and on the list rows:{ rating, reasons, note, used, at },nulluntil anything was sent;ratingandusedstaynulluntil reported. (The webhook'sgenerationcarries the same field; it is normallynullat delivery.) - It never changes the result, the price or the review, and never triggers a re-render. To change a piece, ask for a revision (§6).
- Only succeeded generations of your organization take feedback:
404 not_foundfor an unknown id,409 not_succeededfor a job that is still running or failed,400 invalid_bodyfor an empty body or an unknown reason.
6. Revisions
Ask for a change on a finished result; same async contract, usually cheaper than a fresh run.
curl -X POST "$BASE/api/v1/generations/{id}/revise" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{ "instruction": "Make the price about 40% larger and move the CTA bar higher.", "webhook": { "url": "https://your.app/hooks/b2bmedia", "metadata": { "stockNumber": "3310" } } }'
The new generation carries parentId. Text, prices and the car are re-verified after every revision.
7. Other endpoints
| Endpoint | Purpose |
|---|---|
GET /api/v1/usecases | Catalogue with JSON Schema for every input and current price estimates |
GET /api/v1/generations?limit=50 | Recent results |
GET/POST /api/v1/brands, PATCH/DELETE /api/v1/brands/{id} | Brands: list, create, update, set default ({ "default": true }), delete — see 4.0 |
GET /api/v1/credits | Balance in cents |
POST /api/v1/analyze { usecase, photos } | Pre-fill make/model/year/color/scene from photos (each value carries a confidence; treat year and trim as guesses) |
POST /api/v1/generations/{id}/feedback { rating?: 1 | -1, reasons?: string[], note?, used?: boolean } | Tell us whether the piece was good and whether it was used (👍/👎 with reasons, used when published); partial updates, read back as feedback. See §5 "Tell us whether the piece was used"; the result is unchanged |
PUT /api/v1/dealers/external/{externalId}, GET /api/v1/dealers/{id} | Dealers you serve (§7a) |
POST /api/v1/capture-sessions, GET/DELETE /api/v1/capture-sessions/{id}, POST …/{id}/tokens, POST …/{id}/close | Capture sessions and session tokens for phones (§7a) |
POST /api/v1/capture-sessions/{id}/uploads, PUT {uploadUrl}, POST …/uploads/{uploadId}/complete | Resumable photo uploads for a capture session (§7b) |
GET /api/v1/capture/spec | Capture spec: shot lists, overlays, thresholds, copy (§7b) |
GET/POST /api/v1/vehicles, GET/PATCH /api/v1/vehicles/{id}, POST …/{id}/shoots, GET …/{id}/photos, GET …/{id}/generations | Your vehicle inventory, its shoots, photos and pieces (§7c) |
GET /api/v1/vin/{vin} | Validate and decode a VIN (§7c) |
POST /api/v1/quote { usecase, input, tier?, sessionId? } | What a piece would use from your balance before you start it (§7c) |
DELETE /api/v1/me | Delete the signed-in user's account (§7c) |
7a. Dealers and capture sessions (for mobile capture, Phase 0)
For platforms that serve many dealers and want their dealers' phones to photograph vehicles for Zivdeo. Your
backend keeps the API key, registers each dealer once, and opens a capture session per vehicle; the
phone receives only the session's short-lived token. Phase 0: sessions, tokens, dealers, the capture spec,
resumable uploads, pre-flight checks, quotes and generation with a session token are live (§7b). The phone may start
the session's pieces itself (generatedBy: "device"); your backend can still generate with its API key as in §2, using
the uploaded photos' URLs, when it does not.
Dealers
PUT /api/v1/dealers/external/{externalId} creates or updates the dealer you know by externalId (your own id:
letters, digits and . _ : @ -, up to 128). Idempotent: run it from a nightly sync. 201 when created, 200
when updated; fields you omit keep their current value (defaults on create).
{
"name": "Kayalar Motors",
"brand": { "logoUrl": "https://…", "phone": "(281) 555-0142", "website": "kayalarmotors.example", "primaryColor": "#0f172a", "accentColor": "#c1121f", "currency": "USD" },
"defaults": { "tier": "quality", "inputs": { "car-dealership/studio-set": { "wallTone": "warm", "numberPlate": "logo" } } },
"consent": { "showroom": false, "plates": "treat", "people": "warn", "telemetry": true },
"limits": { "dailyCents": 5000 },
"status": "active"
}
→ { "dealer": { "id": "dlr_…", "externalId": "acme-0042", "name": "…", "brandId": "dlr_…", "defaults": {…}, "consent": {…}, "limits": {…}, "status": "active", "createdAt": "…", "updatedAt": "…" } }
nameis required.brand(same fields as §4.0) creates or updates the dealer's own saved brand and links it; it never becomes your organization's default brand and is left out ofGET /api/v1/brands(and your account page) unless you ask with?include=dealer. Or sendbrandIdto link one of your saved brands (not both).defaults.inputsare partial inputs per use-case id, checked against that use-case's fields: an unknown use-case, an unknown field or a wrong value → 400invalid_bodywith paths likedefaults.inputs.car-dealership/studio-set.wallTone.consentdefaults:showroom: false(becomes the generations'showroomConsent; the phone cannot change it),plates: "treat",people: "warn",telemetry: true.limits(dailyCents,monthlyCents, null = none) cap what the dealer's phones reserve and spend per UTC day and calendar month (UTC), across all its sessions: a phone generation past a cap answers 402dealer_budget_exceeded(perioddayormonth,limitCents,spentCents,requiredCents,resetsAt). Reserved amounts count until the piece settles (then its charge counts) or fails (then nothing does).usageon the dealer shows the current day's and month's cents. Your own API-key generations are never capped.status:activeorsuspended(a suspended dealer gets no new sessions and every token of its sessions stops working at once).
GET /api/v1/dealers/{id} returns { dealer }; 404 dealer_not_found for an id of another organization.
Capture sessions
POST /api/v1/capture-sessions (API key or a signed-in user; a session token gets 403 scope_denied):
{
"dealer": { "externalId": "acme-0042" },
"vehicle": { "vin": "1HGCV1F34LA012345", "stockNumber": "4471", "year": 2020, "make": "Honda", "model": "Accord", "bodyClass": "sedan" },
"usecases": ["car-dealership/studio-set", "car-dealership/walkaround-video"],
"shotList": "dealer-standard",
"brandId": null,
"brand": { "phone": "(512) 555-0142" },
"inputDefaults": { "car-dealership/studio-set": { "wallTone": "grey", "numberPlate": "logo" } },
"tier": "quality",
"budgetCents": 400,
"ttlSeconds": 3600,
"webhook": { "url": "https://partner.example/hooks/zivdeo", "metadata": { "leadId": "L-991" } },
"capturedBy": { "externalId": "agent-77" },
"externalRef": { "ref": "partner-vehicle-123" }
}
| Field | |
|---|---|
dealer | { "externalId" } or { "id": "dlr_…" } (required) |
vehicle | vin and/or stockNumber required. VIN: 17 characters without I, O, Q (lower case is accepted and upper-cased); the North American check digit is computed and returned as vinCheck: "ok" | "mismatch" (advisory, never refused). bodyClass: sedan · suv · pickup · van · coupe-hatch, picks the overlays; omitted → suv |
usecases | 1–10 use-case ids the phone may later quote and generate. A use-case that is unknown, personal or still in testing → 403 usecase_not_allowed with usecases[] |
shotList | dealer-standard (default) · walkaround-quick · exterior-only |
brandId, brand | null/omitted → the dealer's brand; "none" → unbranded; an id → that saved brand. brand overrides on top. Fixed for the session |
inputDefaults | Partial inputs per use-case of this session, on top of the dealer's defaults.inputs; checked against the use-case's fields (unknown fields → 400) |
tier | Fixed for the session; omitted → the dealer's default, else your organization's at generate time |
budgetCents | Required, 1–100000: the most this session's generations may reserve and charge in total |
ttlSeconds | Life of each session token: default 3600, 60–86400 (never past the session's own expiry) |
webhook | { url, metadata?, secret? } as in §2; fixed for the session; secret is never returned |
capturedBy | Optional { externalId } (≤ 128): who shoots, in your system (an agent or user id); stored on the session for your audit |
externalRef | Optional { ref } (≤ 128): your own reference for this vehicle or job; returned with the session |
scopes | Optional: narrow the token to a subset of the default scopes below. revise with an API key → 400 invalid_body (signed-in users only for now) |
201
{
"session": {
"id": "cs_01JBX4…",
"status": "open",
"dealerId": "dlr_8sK2…", "dealerExternalId": "acme-0042",
"vehicle": { "vin": "1HGCV1F34LA012345", "vinCheck": "ok", "stockNumber": "4471", "year": 2020, "make": "Honda", "model": "Accord", "bodyClass": "sedan", "bodyClassSource": "given" },
"usecases": ["car-dealership/studio-set", "car-dealership/walkaround-video"],
"scopes": ["uploads:write", "preflight", "quote", "generate", "generations:read", "feedback"],
"tier": "quality", "brandId": "dlr_8sK2…",
"budgetCents": 400, "heldCents": 0,
"limits": { "uploads": 40, "preflightPhotos": 60, "generations": 6 },
"counters": { "uploads": 0, "preflightPhotos": 0, "generations": 0 },
"spec": { "shotList": "dealer-standard", "specVersion": "2026-11-03", "url": "/api/v1/capture/spec?shotList=dealer-standard&bodyClass=sedan" },
"webhook": { "url": "https://partner.example/hooks/zivdeo", "metadata": { "leadId": "L-991" } },
"expiresAt": "2026-11-09T18:00:00.000Z"
},
"token": "zcs_eyJhbGciOiJIUzI1NiIs…",
"tokenExpiresAt": "2026-11-02T19:00:00.000Z",
"warnings": ["balance_below_budget"]
}
A session lives 7 days. warnings: balance_below_budget (your balance is lower than budgetCents; the session is
created anyway), dealer_brand_missing (the dealer's brand was deleted; the session is unbranded). heldCents is
what the session's generations have reserved or been charged so far; budgetCents − heldCents is what is left.
spec.url points at the capture spec (shot list, overlays, thresholds, §7b).
Other session routes:
| Route | Who | Answer |
|---|---|---|
POST /api/v1/capture-sessions/{id}/tokens { ttlSeconds? } | API key / signed-in user | 200 { token, tokenExpiresAt }, also for a closed session (its token can then only follow the session's generations, see below); 409 session_revoked (the session was revoked), 410 session_expired (past its 7 days, closed or not), 403 dealer_suspended, 403 account_suspended. Your SDK's getToken() callback ends here, through your backend |
GET /api/v1/capture-sessions/{id} | API key / user, or a token of this session | 200 { session } with uploads (the completed uploads in shot order, each as in §7b step 3, with kept when the close named one per shot), generations (the ids of the generations the session started, recorded when each was started; a phone that was killed finds them here), skipped, closedBy, generatedBy and inputDefaults; lets an app resume after being closed. A token sees neither the webhook nor who created the session. Your backend reads an expired session with status: "expired"; its token gets 410 session_expired |
POST /api/v1/capture-sessions/{id}/close | a token of this session, or API key / user | Body optional, see below. 200 { session } with status: "closed" and closedBy (device for a token, backend for your credentials); sends capture.completed. Repeating it is harmless (the first close stands). A session past its 7 days is closed by expiry at that moment (closedBy: "expiry", the body is ignored) and answers 200, the same as after our periodic sweep; 401/409 session_revoked for a revoked session. After close the token can still read the session, and read and rate the generations the session started (generations:read, feedback); uploads, pre-flight, quote and generate answer 409 session_closed (except a companion mask, accepted for 10 minutes after close) |
DELETE /api/v1/capture-sessions/{id} | API key / user only | 200 { session } with status: "revoked": every token of the session answers 401 session_revoked from now on; uploads already stored stay. Repeating it is harmless |
Close body (all fields optional; the SDK sends it when the dealer finishes and every upload is complete):
{
"skipped": [{ "shotId": "E8", "reason": "against-wall" }],
"shots": [{ "shotId": "E1", "uploadId": "upl_01JC…" }],
"generations": ["0c9d5e4c-1b2a-4f00-9e1d-123456789abc", { "id": "5b0e…", "usecase": "car-dealership/studio-set" }]
}
skipped: shots the dealer could not take;reasonagainst-wall·no-room·other.shots: the upload to use for a shot when it is not the latest attempt (the dealer kept an earlier photo after a retake). Each must be a completed upload of that shot in this session, else 400invalid_body.generations: generations started from this capture with the session token (ids, or{ id, usecase }), up to 20. Each must be a generation this session started (a statedusecasemust match), else 400invalid_bodywith the path. They only confirm what the session already recorded when each was started:generationIds,generations(with their use-cases) andgeneratedBy: "device"are kept as recorded, whoever closes. Your backend should not generate the same set again whencapture.completedcarries them.
Webhook capture.completed goes to the session's webhook once, when the session is closed: by the phone, by your
backend, or by us when nobody closed it within 7 days (closedBy: "expiry", with whatever photos arrived). Same
delivery, retries and signature as §2 (X-B2BM-Event: capture.completed; the session's webhook secret if it has one,
else your organization's):
{
"id": "evt_cs_01JBX4…_capture.completed",
"event": "capture.completed",
"createdAt": "2026-11-02T18:04:00.000Z",
"sessionId": "cs_01JBX4…",
"metadata": { "leadId": "L-991" },
"session": {
"id": "cs_01JBX4…", "status": "closed", "closedBy": "device", "closedAt": "…", "expiresAt": "…",
"dealer": { "id": "dlr_8sK2…", "externalId": "acme-0042" },
"vehicle": { "vin": "1HGCV1F34LA012345", "stockNumber": "4471", "bodyClass": "sedan", … },
"externalRef": { "ref": "partner-vehicle-123" }, "capturedBy": { "externalId": "agent-77" },
"usecases": ["car-dealership/studio-set"], "shotList": "dealer-standard", "specVersion": "2026-11-03",
"shots": [ { "shotId": "E1", "uploadId": "upl_01JC…", "attempt": 1, "originalURL": "https://firebasestorage.googleapis.com/…",
"width": 2560, "height": 1920, "bytes": 2210443, "contentType": "image/jpeg", "small": false } ],
"skipped": [ { "shotId": "E8", "reason": "against-wall" } ],
"missing": ["I3"],
"uploads": [ … every completed upload, retakes included … ],
"incomplete": [ { "shotId": "E4", "uploadId": "upl_01JD…", "attempt": 1, "receivedBytes": 524288, "totalBytes": 2483112 } ],
"generatedBy": "device", "generationIds": ["0c9d5e4c-…"], "generations": [ { "id": "0c9d5e4c-…", "usecase": "car-dealership/studio-set" } ],
"preflight": { "views": ["front-left", "side-left"], "checks": [], "usecases": { "car-dealership/studio-set": { "ok": true, "ready": true, "staged": 2, "asShot": 0 } }, … }
}
}
Every photo in shots and uploads that was checked carries its pre-flight verdict as preflight (§7b); the
session's preflight is the set verdict over those results (null when no photo was checked).
shots is the set, one photo per shot in the spec's order (the kept upload, else the latest attempt; skipped shots
left out); missing lists required shots with neither a photo nor a skip; incomplete lists uploads that were reserved
but never completed (no usable photo). Use originalURL as use-case input or copy
the photos to your own storage. Uploads never completed are discarded 7 days after their last byte.
A session id that is not your organization's, or is not a well-formed session id, answers 404 session_not_found
(the same for dealer ids: 404 dealer_not_found); a token used on another session's routes answers 403
scope_denied. A token of a revoked session, or of a suspended dealer, answers 401 session_revoked.
Session tokens
zcs_ + a signed token. Treat it as opaque: send it as Authorization: Bearer zcs_…, refresh it through your
backend before tokenExpiresAt (or when a call answers 401 token_expired: get a new one once and retry).
Never put an API key on a phone.
Scopes: uploads:write, preflight, quote, generate, generations:read, feedback (default set), and
revise (off by default, our own app only for now). Routes a session token may call, each for its own session's
data only:
| Route | Scope |
|---|---|
GET /api/v1/capture-sessions/{id}, POST /api/v1/capture-sessions/{id}/close | any token of that session |
GET /api/v1/capture/spec | any token |
POST /api/v1/capture-sessions/{id}/uploads, POST …/uploads/{uploadId}/complete | uploads:write (the upload URL itself needs no token) |
POST / GET /api/v1/capture-sessions/{id}/preflight | preflight |
POST /api/v1/capture-sessions/{id}/quote | quote |
POST /api/v1/generate | generate |
GET /api/v1/generations/{id} | generations:read (generations this session started; any other id 404) |
POST /api/v1/generations/{id}/feedback | feedback (same) |
Every other route answers 403 scope_denied; a token used on another session's routes too.
Order on the phone: upload every shot → pre-flight (per shot, then scope: "set") → quote (optional) → generate
each use-case → close the session (with skipped, shots and the generation ids) → keep polling
GET /api/v1/generations/{id} and send feedback. Close does not stop the phone from following its pieces:
generations:read and feedback keep working on a closed session (until it is revoked or reaches its 7 days, and a
new token can be minted for a closed session); everything else answers 409 session_closed.
7b. Capture spec, uploads and limits (for mobile capture, Phase 0)
Capture spec — GET /api/v1/capture/spec?shotList=dealer-standard&bodyClass=sedan
What the phone shoots, where the car sits in the frame, every threshold of the on-device checks, the output floors and
the copy. Any valid session token (whatever its scopes) or any backend credential. shotList: dealer-standard (default) ·
walkaround-quick · exterior-only; bodyClass: sedan · suv · pickup · van · coupe-hatch (default suv); anything
else → 400 invalid_query. Cacheable: the answer carries ETag and Cache-Control: private, max-age=300; send
If-None-Match and get 304 while nothing changed. specVersion changes whenever anything in it does.
{
"specVersion": "2026-11-08.1",
"shotList": "dealer-standard",
"bodyClass": "sedan",
"output": { "longSide": 2560, "maxLongSide": 4096, "jpegQuality": 0.9, "minLongSide": 1600, "maxUploadBytes": 26214400 },
"thresholds": { "autoRollDeg": 1.5, "rollWarnDeg": 1.5, "rollStrongWarnDeg": 3, "autoPitchMinDeg": -8, "autoPitchMaxDeg": 3, "pitchMinDeg": -15, "pitchMaxDeg": 5,
"stableRadPerSec": 0.3, "stableHoldMs": 400, "blurMin": 60, "blurMinByDevice": {}, "analysisLongSide": 512,
"clippedLumaLevel": 250, "clippedMaxPct": 8, "meanLumaMin": 50, "metricsHz": 10, "maxRetakesAsked": 3,
"advanceDelayMs": 700, "exposureReleaseEV": 1.5,
"rearmMinMs": 1500, "rearmYawDeg": 35, "rearmSceneDelta": 0.25, "vehiclePresenceMin": 0.3 },
"measurements": { "subjectMask": true, "horizon": true, "camera": true, "light": true, "plateCandidate": true },
"autoShutter": { "requireSubjectCentered": true, "pitchGate": "advisory" },
"framing": { "edgeBand": 0.015, "boxCoverMax": 0.7, "boxCoverMin": 0.15, "sideCut": 0.12, "endCut": 0.25, "fillMin": 0.05, "fillMax": 0.85, "liveBandSlack": 0.03 },
"shots": [
{ "id": "E1", "kind": "exterior", "view": "front-left", "required": true, "orientation": "landscape", "lens": "wide", "lensLock": true, "autoCapture": true,
"overlay": { "targetBox": { "minWidth": 0.75, "maxWidth": 0.88, "groundLineMin": 0.8, "groundLineMax": 0.86, "topMargin": 0.08, "sideMargin": 0.05 },
"tyreMarkers": [0.36],
"silhouetteUrl": "https://zivdeo.com/capture/overlays/sedan/front-left.svg",
"referenceImageUrl": "https://zivdeo.com/capture/reference/sedan/front-left.jpg" },
"checks": ["orientation", "level", "pitch", "stable", "blur", "exposure", "resolution"],
"title": "Driver-side front corner", "hint": "Front corner on the driver's side, whole car inside the box." },
{ "id": "I1", "kind": "interior", "required": true, "orientation": "any", "lens": "ultra-wide", "lensLock": false, "autoCapture": false,
"checks": ["blur", "exposure", "resolution"], "title": "Driver seat", "hint": "Through the open driver's door." }
],
"strings": { "shot.E1.title": "Driver-side front corner", "shot.E1.hint": "…" },
"shotLists": ["dealer-standard", "walkaround-quick", "exterior-only"],
"bodyClasses": ["sedan", "suv", "pickup", "van", "coupe-hatch"],
"skipReasons": ["against-wall", "no-room", "other"],
"provisional": ["thresholds.blurMin", "thresholds.vehiclePresenceMin"]
}
- Shots:
E1–E8exteriors in walking order (driver-side front corner first; views are the car's own sides, on a left-hand-drive car "left" is the driver's side),I1–I8interiors (I3, the instrument cluster, is a detail),D1–D8details.walkaround-quick=E1–E8+I1,I2,I6;exterior-only=E1–E8. Every list fits the studio set (≤ 24 photos) and the walk-around (2–12 exteriors, ≤ 8 interiors). lensLock(spec 2026-11-07): the camera stays on the shot'slensat 1× from framing to the shutter, with no lens switch and no zoom; true for every exterior (the set's invariants and the optics record need one lens around the car), false for interiors and details. An SDK release that does not know the key reads it as false in its core and as true for exteriors in its vehicle module. SDK 0.2.8 applied the next shot's zoom before the capture, so some records name one lens and carry another's field of view; the server treats such optics as unknown (no principal point and a lens label that disagrees with the zoom or field of view).targetBoxis in fractions of the landscape 4:3 frame: car width betweenminWidthandmaxWidth, tyre contact line betweengroundLineMinandgroundLineMax(from the top), margins above the roof and at the sides. Since spec 2026-11-08.1 the width band gates the auto shutter on every exterior (SDK 0.2.9+,framing.liveBandSlack):E10.75–0.88, the other three-quarter corners 0.70–0.88, sides 0.72–0.90, front and rear 0.55–0.80.output.longSideandjpegQualityare floors for the phone: an app may raise them, never lower them.maxLongSideis our cap when we normalise an upload. Photos whose long side is belowminLongSideare accepted and flaggedsmall.- Overlay silhouettes and reference photos are being drawn: their URLs may answer 404 until then; draw a plain target box meanwhile.
- The auto shutter: after a shot it fires again only after
rearmMinMs(1500) and once the phone has moved on, its heading turned byrearmYawDeg(35°; on exterior shots the turn is required, the picture changing byrearmSceneDelta, 0.25 of 1, alone re-arms only interiors and details), so a position that still holds level is not shot twice; and only while the on-device vehicle detector sees a vehicle with at leastvehiclePresenceMin(0.3 of 1; provisional). What each photo measured for these goes in its capture record (rearm,checks.subject). WithautoShutter.requireSubjectCenteredthe shutter also waits until the vehicle the phone detects sits centred in the shot's target box (an SDK release that does not know the key ignores it). - Pitch:
autoShutter.pitchGateis"enforced"or"advisory"(spec 2026-11-08.1 serves"advisory"; SDK 0.2.9+). Enforced (SDK 0.2.7–0.2.8, which do not read the key): on exterior shots the auto shutter fires only while the camera pitch is withinautoPitchMinDeg…autoPitchMaxDeg(−8…+3°) and the viewfinder says "lower the phone and tilt it up" or "tilt it down" meanwhile. Advisory: pitch never holds the shutter; the tilt cue turns amber outsidepitchMinDeg…pitchMaxDeg(−15…+5°) and such a shot is advised afterwards, as a manual one always is (the manual shutter is never held). Interiors and details carry no pitch rule. framing(SDK 0.2.5+): the live framing gate. The auto shutter holds while the detected subject's box touches the frame's left, right or top withinedgeBandof the frame, or covers more thanboxCoverMaxor less thanboxCoverMinof it; right before firing, a mask of the live frame (about 55 ms on an iPhone 17) is judged with the same cut rule pre-flight uses on the photo (sideCutof the height on a side,endCutof the width top or bottom, fill withinfillMin…fillMax), and a cut outline holds the shutter with the cut words. The manual shutter is never held. What the gate saw at the shutter goes in the capture record aschecks.subject.liveCut(edges touched),liveFill(the box's share of the frame) andmaskAgreed(null when no mask check ran).framing.liveBandSlack(SDK 0.2.9+, default 0.03): on a shot with a target box the subject's width must lie withintargetBox.minWidth…maxWidth, the live box with ± this share of the frame width, the pre-fire mask exactly; it replaces theboxCoverMin/boxCoverMaxarea rule on those shots (cut edges and the mask's fill stay). After the shot the judged width is an advised checkboxWidth("Step closer." / "Step back."), recorded aschecks.results[{ code: "boxWidth" }]andchecks.subject.liveBoxWidth/maskBoxWidth.measurementsswitches the device measurements the phone records after the shutter (capture recordmeasurements):subjectMask(the vehicle outline and its companion mask, which pre-flight's fast path and a light pre-flight read),horizon,camera,lightandplateCandidate(the plate area that seeds our plate search). A key set tofalseswitches that measurement off; a missing key means on.provisionalnames values still being calibrated on real phones.
Uploads — resumable, checked and normalised on our side
Three steps per photo. The phone uses its session token for 1 and 3; your backend may do the same with its API key for photos it holds.
1. Reserve — POST /api/v1/capture-sessions/{id}/uploads (scope uploads:write)
{ "shotId": "E1", "attempt": 1, "bytes": 2483112, "crc32c": "n03x6A==", "contentType": "image/jpeg", "fileName": "E1.jpg",
"capture": { "capturedAt": "2026-11-02T17:02:11Z", "device": "iPhone18,3", "os": "iOS 26.1", "pitchDeg": -2.1, "rollDeg": 0.4,
"checks": { "blurScore": 412, "clippedPct": 0.8, "stable": true } } }
→ 201
{ "upload": { "id": "upl_01JC…", "status": "pending", "shotId": "E1", "attempt": 1, "totalBytes": 2483112,
"receivedBytes": 0, "offset": 0, "chunkBytes": 8388608,
"uploadUrl": "https://zivdeo.com/api/v1/capture-sessions/cs_01JBX4…/uploads/upl_01JC…?k=cs1.Xk3…",
"expiresAt": "2026-11-09T18:00:00.000Z", "createdAt": "…", "completedAt": null } }
shotIdis a shot of the session's shot list (§7b spec);attempt(1–99, default 1) numbers retakes. Both may instead be sent insidecapture(capture.shotId,capture.attempt). Reserving the same shot and attempt again answers 200 with the same upload (same URL, current offset) and is not counted again; with another size or checksum → 409upload_conflict(use the next attempt number).bytes: the exact size of the file you will send, up to 25 MB (413file_too_large).crc32c: base64 of the big-endian CRC32C of the file (the usual form in resumable upload protocols).contentTypeis optional and only screened (JPEG, PNG, WebP; HEIC/HEIF and anything else 415); the real type is read from the bytes at step 3.capture(≤ 4096 bytes) is your capture record, stored with the upload. Only these fields are accepted (any other → 400invalid_body):shotId,attempt,expectedView,capturedAt,device(model),os,lens,pitchDeg,rollDeg,checks(blurScore,clippedPct,meanLuma,rollDeg,pitchDeg,angularSpeed,stable,orientation,width,height,results[{ code, level, passed, value, messageKey, message }],subject { present, label (≤ 40), score 0–1, liveCut, liveFill, maskAgreed, liveBoxWidth 0–1, maskBoxWidth 0–1 }: whether a vehicle was detected in the frame and how confident the detection was, what the live framing gate saw, and the subject box's width as a share of the frame from the live box and from the pre-fire mask, SDK 0.2.9+),retakes,skipReason,timing(shutterToReadyMs,shutterToDeliveredMs,deliveredToCheckedMs,sincePreviousShutterMs) and the camera statesource,trigger,zoom,thermal,exposure(durationSeconds,iso,gains,lens,targetOffsetEV,held,releasedForShot) andrearm(elapsedMs,yawDeltaDeg,sceneDelta: how far the phone moved since the previous photo). Device measurements (SDK 0.2) go undermeasurements(fractions are of the photo as uploaded; every one optional and nullable):ms(time the phone spent),ran [](which ones ran),subjectMask(boxFrac {x,y,w,h},fill,edgeTouch {l,r,t,b},contacts [{x,y}]≤ 4,confidence0–1,ms,companion"mask"or null: a rough outline of the vehicle computed on the phone),horizon(visionDeg?,gravityDeg,agreeWithin?),ground(yFrac: the ground vanishing line as a fraction of the height, which may lie well outside 0–1 on a steep pitch, accepted between −10 and 10;method),camera(hfovDeg,vfovDeg,focalPx?,principal {x,y}? as fractions,zoom,lens,heightM?,heightMethod?: the field of view and the camera's estimated height),light(lr,tb, −1…1: a light-direction estimate) andplateCandidate(box {x,y,w,h},confidence: a candidate plate area, never its text). Fields marked ? may be null. No location, ever.- Companion mask. With
companion: { "of": "upl_…", "kind": "mask" }(and noshotId/attempt: they are the photo's) the reservation is for the phone's vehicle mask of that photo: a PNG (white = vehicle, or an alpha mask) of at most 1 MB, sent and completed like a photo. It is not counted towardlimits.uploadsbut toward its own ceiling, twice that limit (429upload_limit_reached; every reservation counts, a refused one too); one per photo attempt (the same file again is the same upload, another one 409upload_conflict). Complete checks that it is a whole PNG: 8-bit greyscale, greyscale + alpha or RGBA, at most 2048 px on its long side, not cut short (415 otherwise), and reads its size; it is stored as sent, and the photo then carriescompanions.mask { uploadId, url, width, height }(in GET session andcapture.completed). A mask is never a photo: not in the set, pre-flight or generation inputs. A mask whose aspect ratio differs from its photo's by more than 2 % is kept but not used. - After Finish. For 10 minutes after the session is closed by the phone or your backend (not after a revocation or
an expiry), a companion mask can still be reserved, sent and completed, so the last one in flight lands. Photos are
refused (409
session_closed).capture.completedwent out at close: a mask that lands afterwards only appears on its photo (companions.mask), with no second webhook. - Each new reservation counts toward the session's
limits.uploads(40): 429upload_limit_reachedafter that.
2. Send the bytes — PUT {uploadUrl} (no Authorization header: the key in the URL is the credential, valid until
the session expires; it lets a background transfer run without a fresh token)
- Whole file in one request (≤ 8 MB): the bytes as the body, no
Content-Range. - In chunks:
Content-Range: bytes <first>-<last>/<total>with that slice as the body. Chunks are at mostchunkBytes(8 MB); every chunk but the last carries at least 256 KiB. - Answer: 308 with
Range: bytes=0-<last stored byte>(noRangeheader while nothing is stored) andUpload-Offset: <bytes stored>while bytes are missing; 200 once every byte is stored. Both carry progress only:{ "upload": { "status", "receivedBytes", "offset", "totalBytes" } }(the stored photo comes from step 3). One hour after the upload completed, its URL answers 410upload_expired. - After an interruption:
PUT {uploadUrl}withContent-Range: bytes */<total>and an empty body (orGET/HEADon the URL) answers the same way: continue atUpload-Offset. Bytes that arrived before a connection dropped are kept (from 256 KiB on). Re-sending bytes already stored is harmless (they are skipped); a chunk that starts past the stored end → 409upload_offset_mismatchwithoffset. - 401
upload_url_invalid(key missing or altered), 410upload_expired(session expired, or the upload was refused at step 3: reserve again), 409session_closed, 401session_revoked, 400invalid_range(malformedContent-Range, a size other than the reserved one, a body longer than the range).
3. Complete — POST /api/v1/capture-sessions/{id}/uploads/{uploadId}/complete (scope uploads:write, no body)
→ 200
{ "upload": { "id": "upl_01JC…", "status": "ready", "shotId": "E1", "attempt": 1, "totalBytes": 2483112, "receivedBytes": 2483112, "offset": 2483112,
"url": "https://firebasestorage.googleapis.com/…?alt=media&token=…", "originalURL": "https://firebasestorage.googleapis.com/…",
"width": 2560, "height": 1920, "contentType": "image/jpeg", "bytes": 2210443, "small": false,
"normalized": { "from": "image/jpeg", "orientation": 6, "rotatedDeg": 90, "mirrored": false, "exifStripped": true, "resizedFrom": { "width": 3024, "height": 4032 }, "reencoded": true },
"normalizeCode": null } }
- We check the size and the CRC32C, read the type from the bytes, apply the EXIF orientation to the pixels, remove
EXIF, GPS, XMP and every other metadata block, cap the long side at the spec's
output.maxLongSide(4096 px) and store a JPEG at the spec's quality. An upright JPEG that needs no scaling only loses its metadata (no second compression:reencoded: false).url(also asoriginalURL) is the stored photo; use it as a use-case input. small: true: the long side is underoutput.minLongSide(1600 px). Accepted; the studio never upscales, so expect softer results.- HEIC/HEIF is not accepted yet: 415
unsupported_media_type(sniffed: "image/heic") with a message asking for JPEG. Convert on the phone before uploading (our SDK always sends JPEG). - Repeating complete returns the same upload; two completes at once share one result (the second waits up to 30 s,
then 409
upload_completing: retry). 409upload_incompletewithreceivedBytes/offsetwhen bytes are missing (send the rest, then complete); 422checksum_mismatch(the stored bytes were discarded: send the whole file again from offset 0 to the sameuploadUrl); 415unsupported_media_typewithsniffed(what the bytes are, or null) and 422image_unreadable(damaged, or over 52 megapixels): both final for this upload, reserve a new attempt. - Chunks are temporary and kept apart from stored photos: deleted once the upload completes or is refused; an upload
that never completes is discarded 7 days after its last byte (status
abandoned), and no chunk is kept longer than 8 days in any case.
Pre-flight — check photos before anything is charged
POST /api/v1/capture-sessions/{id}/preflight (scope preflight, or your backend's credentials) checks completed
uploads of the session with the same measurements the studio set and the walk-around use, so the dealer retakes a bad
shot while still at the car. The SDK sends each shot right after its complete. Body, one of:
{ "uploadId": "upl_01JC…" }
{ "uploadIds": ["upl_01JC…", "upl_01JD…"], "scope": "set" }
{ "photos": [ { "upload": "upl_01JC…", "expected": "E1" } ], "scope": "photo", "tier": "light" }
Up to 8 uploads per call. expected, when sent, must be the upload's own shot (400 otherwise). scope: "set"
adds the set verdict (below); with it you may send the dealer's ring as generate takes it (shots, skipped).
tier: full (the default) or light (below). → 200
{
"specVersion": "2026-11-03",
"photos": [{
"index": 0, "uploadId": "upl_01JC…", "shotId": "E1", "status": "retake", "cached": false,
"checks": [ { "code": "cut_left", "level": "enforced", "message": "The car is cut off at the left edge. Step back until the whole car fits the outline." } ],
"expectedView": "front-left", "measuredView": "front-left", "end": "front", "kind": "exterior-partial",
"studio": "staged", "studioReasons": [],
"image": { "width": 2560, "height": 1920, "small": false },
"subject": { "box": { "x": 0, "y": 0.31, "w": 0.83, "h": 0.42 }, "fill": 0.29, "edgeTouch": { "left": 0.34, "right": 0, "top": 0, "bottom": 0.02 }, "cut": ["left"], "obstructed": false },
"plate": { "found": true, "box": { "x": 0.21, "y": 0.66, "w": 0.06, "h": 0.03 } },
"placeholder": null, "people": false, "unchecked": [], "uncheckedReasons": {}, "tier": "full",
"off": [ { "check": "placeholder", "reason": "not_applicable" } ],
"upload": "upl_01JC…", "expected": "E1", "view": "front-left", "verdict": "retake",
"checkedAt": "2026-11-02T17:02:19.000Z", "pending": false
}],
"pending": false,
"costCents": 0
}
status:retakewhen anenforcedcheck failed; elseuncheckedwhen a check that can be enforced could not run this time (a service was slow or down: a photo is never calledokwithout its enforced checks, nor refused for a failure on our side); elseadvisedwhen onlyadvisedchecks failed; elseok.verdictsays the same asok·warn·retake·unchecked;upload,expectedandviewrepeatuploadId,shotIdandmeasuredView(the SDK reads those names). Showuncheckedas "not checked yet" and ask again.- Checks (code, level):
resolution(long side under 1600 px, enforced) ·placeholder(a "photo coming soon" graphic, enforced) ·kind_mismatch(an exterior shot that shows an interior, a close-up or no car; an interior shot that shows the whole car from outside; enforced) ·view_end_mismatch(the photo shows the other end of the car, or a profile where a corner or an end was asked for; enforced) ·view_side_mismatch(the other flank; advised: our side reading is right about 71 times in 74) ·cut_left/cut_right(the car touches a side over more than 12 % of the height) andcut_top/cut_bottom(over 25 % of the width), enforced ·fill(the car under 5 % or over 85 % of the frame, enforced) ·obstructed(something fused to the car's outline, such as a pole or another car; advised) ·plate_not_found(an end of the car without a plate found, while plates are treated; advised: the plate then stays as photographed) ·people(a face in an exterior; advised, and given 3 s, so it often comes back unchecked; enforced, with the detector's full time, when the dealer'sconsent.peopleisblock). Car checks run on exterior shots (E1–E8). - Fast path. When the photo's capture record carries
measurements.subjectMaskwithconfidence≥ 0.8, the cut, fill and edge checks and thestudiodecision come from the phone's numbers, with the same rules as on our cutout, and no server cutout is made. The phone sendsedgeTouchper frame edge as a share of the vehicle box's side (the box rows, or columns, with vehicle pixels within 0.5 % of that frame edge); we convert it to a share of the frame's side before the cut rules apply. With the companion mask, the obstruction check runs on it. When the record says a mask follows (subjectMask.companion: "mask") and it has not arrived,obstructedstaysuncheckedand the next pre-flight of that photo measures that check alone once the mask is there (free, and it does not use up a re-measure); without a mask promised, or with one we cannot read or that does not have the photo's shape, it isunchecked(advisory) for good. Below 0.8 we cut the photo out ourselves. Each check says where its numbers came from (source:deviceorserver), andsources { subject, studio }sums it up. Ameasurements.plateCandidatepicks the plate when our search finds more than one. Views always come from our classifier. The studio set uses a confident mask the same way: that step costs nothing, so the set is priced without it. studiois what the studio set will do with the photo before its review:stagedin the studio, oras-shot(interiors, details, a car too small or filling the frame) withstudioReasons. It is the studio set's own decision function, not a second one.peoplecomes from a face detector: a person seen from behind is not counted.uncheckedlists checks that did not run, anduncheckedReasonssays why for each:unavailable(a service was slow or down this time) ordeferred(a light request skipped it; see tiers below).offlists checks not run on purpose, never a reason to retake:not_applicable(the placeholder guard on a camera photo the app uploaded with its session token: a camera cannot shoot a "photo coming soon" graphic; your backend's imports and library photos are still checked),not_configured,switched_off.- Results are kept on the upload: asking again for a fully checked photo answers at once (
cached: true). A photo whose result is incomplete (a service failed, timed out or found no free slot) has only its failed checks measured again when you ask again, at most twice; after that the stored result is the answer. Only a photo with no stored result counts toward the session'slimits.preflightPhotos(60; 429preflight_limit_reachedafter that): measuring again is free, and so is all of pre-flight (costCents: 0). - Tiers.
tier: "full"(the default, also forscope: "set") runs every check above.tier: "light"runs the kind and view classifiers and takes the cut, fill and edge checks and thestudiodecision from the phone's outline (the fast path), or from our low-resolution cutout when the phone sent no confident outline; it skips the placeholder guard, the obstruction check, the plate search and the face detector. Those come back inuncheckedwith reasondeferred, and the photo'stierislight. The status rules do not change for a deferred check: an advisory one (obstructed,plate_not_found,peoplewhile people are only warned about) leaves the photookoradvised; an enforceable one (placeholderon an imported photo,peoplewhen the dealer blocks people) leaves itunchecked. A laterfullrequest on the same upload measures only the deferred checks: the stored classifications, outline and cutout are reused, it is not counted again, and it does not use up one of the two re-measures (those are for checks that failed). After it the photo'stierisfull. Alightrequest on a photo already checked answers from the stored result. Each photo a light request checks for the first time counts towardlimits.preflightPhotoslike any first check. Use light while the dealer is shooting (it answers what the phone has to know at the car: wrong kind, wrong end, cut off, too small or too large) and full on the set the dealer creates (the set-scope request before Create). A set verdict over light results is valid: it reads only what light measures, and a photo leftuncheckedwaits there as any unchecked photo does. Pre-flight stays free to you at both tiers; light exists to keep our own cost low on sets that are never created (about a tenth of a full check: the classifiers alone with the phone's outline). - Server-initiated light check (Nov 3 2026). You do not have to ask for the light check of a photo the app
uploaded with its session token: when its
completesucceeds we queue the light check ourselves, it runs within seconds (about 2 s with the phone's outline, about 4 s when we cut the photo out) and its result is stored on the upload. Until it has answered the photo is pending: every photo in a pre-flight answer (POST and GET) carriespending(and the answer a top-levelpending, true when any photo is). A pending photo shows its stored verdict when an earlier run left a check unchecked and another run is coming, or else only{ "uploadId": "upl_01JC…", "shotId": "E1", "status": "unchecked", "upload": "upl_01JC…", "expected": "E1", "verdict": "unchecked", "checkedAt": null, "pending": true }- A
lightPOST for a pending photo answers at once with what is stored (pending: true): nothing is measured in your request and nothing is counted. For a photo whose check has run it answers the stored result (cached: true,pending: false), as always. - When the check could not run (we retry it twice, within about 90 s) or has not answered 2 minutes after the
upload completed, the photo is no longer pending, and the next
lightPOST measures it in the request, once: a second request while that one runs answerspending: true. - A
fullrequest (andscope: "set") is unchanged: it measures in the request whatever the light check is doing, and a light result that lands later never replaces a full one. A photo is counted towardlimits.preflightPhotosonce, whichever of the two checks it first. When the limit is reached the light check is not made and the photo is not pending; a POST then answers 429 as before. - Photos your backend uploads (API key or user credentials) are not checked by themselves: ask for them as before.
- Poll
GET …/preflightevery 3 s while any photo ispending(the top-levelpending), and stop when none is. The phone never has to hold a request open for a photo's check; a request that does not answer in time is not a pass (showuncheckedand keep polling).
- A
- Batch your calls. Send the photos of several shots together (up to 8) when they are ready at the same time: the
classifiers then run once for the batch. Each server instance runs at most 6 checks' provider calls at once, so a
burst of single-photo calls queues on our side; a check that waits more than 20 s for its turn is
uncheckedthis time. - 404
upload_not_found, 409upload_incomplete(complete it first), 410upload_expired(an upload refused at complete), 503temporarily_unavailablewhen no check could run at all (nothing is counted; keep shooting and ask again later; the SDK marks the photosunchecked).
GET /api/v1/capture-sessions/{id}/preflight (same access) answers the stored verdict of the latest photo per shot (the
kept upload once the session is closed; { "status": "unchecked", "verdict": "unchecked", "checkedAt": null } for one
not checked yet), each with pending and checkedAt, a top-level pending, and the set verdict, without measuring
or counting anything. Poll it every 3 s while pending is true (above). photos and the set verdict cover exactly the photos
generate would send (the ring, below): one per shot, the kept attempt, else the latest attempt pre-flight did not
refuse. Skipped shots and photos pre-flight refused (also an attempt beside the one used) are listed in excluded
with their verdicts, so the app can still show them:
{
"specVersion": "2026-11-03",
"photos": [ … ],
"pending": false,
"excluded": [ { "shotId": "E2", "uploadId": "upl_01JD…", "attempt": 1, "reason": "refused", "verdict": { "status": "retake", … } },
{ "shotId": "E8", "uploadId": null, "attempt": null, "reason": "skipped", "verdict": null } ],
"set": {
"views": ["front-left", "rear-left", "rear-right", "front-left"],
"covered": ["front", "left", "right", "rear"],
"missingViews": ["side-left", "rear", "side-right", "front-right", "front"],
"checks": [
{ "code": "duplicate_view", "level": "advised", "shotIds": ["E1", "E7"], "message": "E1 and E7 both show the front corner on the driver's side. …" },
{ "code": "walk_gap", "level": "advised", "shotIds": ["E5", "E7"], "message": "The walk-around cannot move from E5 to E7: … Take E7 so the camera can pass between them." }
],
"walk": { "ok": false, "route": "front-left → rear-left → rear-right → front", "gaps": [ { "from": "E5", "to": "E7", "add": [["E7"]] } ], "unplaced": [] },
"usecases": {
"car-dealership/studio-set": { "ok": true, "ready": true, "staged": 4, "asShot": 1 },
"car-dealership/walkaround-video": { "ok": false, "ready": false, "exteriors": 4, "error": "coverage", "message": "…" }
},
"uncheckedShots": []
}
}
walk_gap: two neighbouring exterior photos (in shot order) that show no common part of the car, so the walk-around would refuse the set (§4.10);addnames the shots that bridge the gap, per way round. A corner photo counts for its flank only when our reading agrees with the shot it was taken for; otherwise it claims its end only. The walk-around measures every photo again before it renders.usecases(the session's): the studio set isreadywith at least one staged photo; the walk-around with 2–12 exterior photos and no gap.uncheckedShotslists the photos not checked yet orunchecked; a readiness that depends on them isready: null(withok: true), neverfalse: a failure on our side is never a retake. Use-cases without a pre-flight rule answerready: null.
Quote — POST /api/v1/capture-sessions/{id}/quote
Scope quote, or your backend's credentials. { "usecase": "car-dealership/studio-set", "input": { … } }, or
{ "items": [ { "usecase", "input" }, … ] } (up to 10). At the top level of either form you may send the dealer's ring
as generate takes it (shots, skipped); it applies to every item. Photo fields an input leaves out are filled from
the ring as for generate. → 200
{ "usecase": "car-dealership/studio-set", "tier": "quality", "typicalCents": 84, "maxCents": 112,
"items": [ { "usecase": "car-dealership/studio-set", "tier": "quality", "typicalCents": 84, "maxCents": 112 } ],
"totalMaxCents": 112, "session": { "budgetCents": 400, "heldCents": 0, "remainingCents": 400 }, "fitsBudget": true, "generationsLeft": 6 }
The input is built and checked exactly as generate does (below), so maxCents is the amount generate will reserve
for the same input; typicalCents is what a typical run is charged. Nothing is reserved. fitsBudget: the phone could
start every item now (the reservations fit remainingCents and the items fit generationsLeft; the dealer's caps are
checked when it starts them). Your backend also gets availableCents (your balance), and may quote a closed session
(a phone only an open one). 400 invalid_input, 403 usecase_not_allowed,
403 input_not_in_session, 400 field_not_allowed (a token sent tier, brand, …).
Generate with a session token — POST /api/v1/generate
Scope generate. The phone sends only { "usecase", "input"?, "id"? }:
{ "usecase": "car-dealership/studio-set", "id": "0c9d5e4c-1b2a-4f00-9e1d-123456789abc",
"input": { "photos": ["https://firebasestorage.googleapis.com/…E1-1.jpg", "…"], "wallTone": "grey" } }
usecasemust be one of the session's (403usecase_not_allowed).brand,brandId,tier,webhook,showroomConsent,dealer,externalRef,sessionIdandvehicleIdcome from the session; sending any of them → 400field_not_allowed(fields[]). A session without a brand generates unbranded (never your organization's default brand).- The input is the session's
inputDefaultsfor that use-case with the request'sinputon top. Every capture photo field the input leaves out (photosfor the studio set,exteriorPhotosandinteriorPhotosfor the walk-around) is filled from the dealer's ring: one photo per shot in shot order, the attempt named inshots(else the session's kept upload, else the latest attempt pre-flight did not refuse), leaving out the shots inskippedand every photo pre-flight refused.shots: [{ shotId, uploadId }]andskipped: [{ shotId, reason }]are the close body's (400invalid_bodyfor a shot not on the list or an upload not of that shot); without them, the latest accepted attempt and no skipped shots. A photo pre-flight never checked is sent. - Every link in the input must be a completed upload of this session (its
url), or a link your backend put in the session'sinputDefaults(a dealer's wall sign): anything else → 403input_not_in_sessionwithpaths[]. A phone never makes our servers fetch an arbitrary link. id(a UUID you choose) makes retries safe: the same id answers the same generation and reserves nothing more. An id already used outside this session → 409id_taken.- The reservation counts toward the session and its dealer: 402
session_budget_exceeded(budgetCents,heldCents,remainingCents,requiredCents) when it would passbudgetCents; 402dealer_budget_exceededwhen it would pass the dealer's daily or monthly limit (§7a Dealers); 429generation_limit_reached(limit) afterlimits.generations(6) generations, final for the session. The session'sheldCentsand the dealer's usage then follow the money: the charge replaces the reservation when the piece is done, a failed piece gives its reservation back, so a 402 can lift once a running piece fails or settles below its reservation. Your balance is reserved and charged as for any generation (§2). - The answer is the usual 202 (§2) without the webhook (results go to the session's webhook, to your backend);
?wait=trueis ignored. The session records the id (generationIds,generations) withgeneratedBy: "device"when the generation is started (not at close), and the generation carriessessionId,dealerIdand the session'sexternalRef(also at the top level of its webhook).
Your backend's generations for a session. With an API key (or a signed-in user), add "sessionId": "cs_…" to an
ordinary generate body (§2) to link the generation to one of your capture sessions, open or closed (typically on
capture.completed): the session records it (generationIds, counters.generations, generatedBy: "backend" unless
the phone generated first), close accepts its id, and it carries sessionId, dealerId and externalRef. Everything
else is yours as usual (body fields, webhook, tier): the session's budget, its generation limit and the dealer's caps
apply to phones only; it counts in the session's counters.backendGenerations, never the phone's
limits.generations. With sessionId you may also send shots and skipped (the dealer's ring, checked like the close
body): photo fields your input leaves out are filled from that ring (else the session's). Without sessionId, shots
and skipped are a 400 invalid_body. A session lists the latest 200 generation ids (generationsDropped counts
older ones). 404 session_not_found, 409 session_revoked.
A session token reads its pieces with GET /api/v1/generations/{id} (scope generations:read) and rates them with
POST /api/v1/generations/{id}/feedback (scope feedback), for generations its session started only (404 for
any other id). Poll every 5 s for the first minute, then every 15 s (Retry-After). The dealer is not the payer: a phone
sees no orgId, holdCents or chargedCents, nor the webhook or who holds the API key; your backend sees them all,
and externalRef (the session's) on the generation and at the top level of its webhook.
Limits for session tokens
Counted per capture session (every token of the session shares them), in one-minute windows:
| Route | Requests per minute |
|---|---|
POST /api/v1/capture-sessions/{id}/uploads (reserve) | 60 |
POST …/uploads/{uploadId}/complete | 60 |
Upload URL: PUT/PATCH chunks, GET/HEAD/bytes */total offset queries | 300 |
GET /api/v1/capture/spec | 30 (cache it; revalidate with If-None-Match) |
POST / GET …/preflight | 60 |
POST …/quote | 60 |
POST /api/v1/generate | 30 |
GET /api/v1/generations/{id} | 120 |
POST /api/v1/generations/{id}/feedback | 60 |
Over a limit: 429 rate_limited with Retry-After (seconds to the next window), X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds); successful answers carry the X-RateLimit-* headers
too. Separately, a session accepts limits.uploads reservations (40; 429 upload_limit_reached), checks
limits.preflightPhotos photos (60; 429 preflight_limit_reached) and starts limits.generations generations
(6; 429 generation_limit_reached) in total; no waiting lifts those. API keys and signed-in users have no request limits yet; spending is bounded by your balance and the
session budget.
7c. Vehicles, VIN decode, quote, account (the Zivdeo dealer app; any API key or signed-in user)
Our iOS dealer app runs on these routes; your backend may use them too. They take an API key or a Firebase ID token
(signed-in users); capture session tokens get 403 scope_denied. Amounts in cents, dates ISO 8601.
Vehicles — /api/v1/vehicles
A vehicle collects the photos of every capture shoot started for it and the pieces made for it. studioSetCount and
flyerCount count its finished studio sets and flyers (price drop, new arrival; revisions included).
{ "id": "veh_01JD…", "vin": "1HGCM82633A004352" | null, "checkDigitValid": true | false | null,
"title": "2003 Honda Accord EX-V6", "year": 2003, "make": "Honda", "model": "Accord", "trim": "EX-V6",
"bodyClass": "sedan" | "suv" | "pickup" | "van" | "coupe-hatch", "color": null, "stockNumber": "A-4471",
"priceCents": 1899000, "mileage": 84210, "status": "active" | "archived", "coverUrl": "https://…" | null, "coverThumbUrl": "https://…" | null,
"photoCount": 12, "studioSetCount": 1, "flyerCount": 2, "lastSessionId": "cs_…" | null, "createdAt": "…", "updatedAt": "…",
"createdBy": { "kind": "user" | "api-key", "userId": "…" | null, "apiKeyId": "…" | null } }
POST /api/v1/vehicles{ vin?, title?, year?, make?, model?, trim?, bodyClass?, color?, stockNumber?, priceCents?, mileage? }→ 201{ vehicle }. Send a VIN or a title. Spaces and dashes in a VIN are ignored; a VIN that is not 17 letters and digits without I, O and Q is refused with 400invalid_body(the message says why). The check digit (position 9) is evaluated, never enforced:checkDigitValidis true/false for North American VINs (first character 1–5) and null for other markets when it does not hold. A VIN without year, make and model is decoded (below) to fill them. Without a title one is made from year, make, model and trim, and it follows those facts until you set your own.bodyClassdefaults to the decoded one, elsesuv. A VIN already on an active vehicle → 409{ "error": "vehicle_exists", "vehicle": { … } }with that vehicle.GET /api/v1/vehicles?status=active|archived&q=&limit=30&cursor=→{ vehicles, nextCursor }, most recently updated first.qmatches title, VIN (spaces ignored), stock number, make and model; passnextCursorback ascursorfor the next page.GET /api/v1/vehicles/{id}→{ vehicle, openSession: { id, expiresAt, missingRequired, studioSetId } | null }: the vehicle's last capture session while it is open, how many required shots of its shot list have no photo yet, andstudioSetId: the studio set (§7d) that session's photos were sent to (the newest set the session opened), null before its first studio photo. "Continue shoot" after an app restart sends the next photo with thissetId, so the walk stays one set.PATCH /api/v1/vehicles/{id}any create field plusstatus→{ vehicle }.nullclears a field (title: nullgoes back to the made-up title). Archive with{ "status": "archived" }; making it active again checks the VIN (409).POST /api/v1/vehicles/{id}/shoots→{ session: { id, expiresAt, studioSetId }, token, tokenExpiresAt, reused }(201 new, 200 reused;studioSetIdas inopenSessionabove, always null for a new session): a capture session for the vehicle (§7a, §7b) and its first session token for the capture SDK. The vehicle's open session is reused while it has more than an hour left; otherwise a new one: your organization's own dealer (externalIdself, created with your organization's name), use-casecar-dealership/studio-set, shot listdealer-standard, your default brand, your default tier, budget = your balance up to $20 (raised on reuse when the balance grew). A zero balance still opens a session (photos cost nothing); generations are refused at the hold. Refresh the token withPOST /api/v1/capture-sessions/{id}/tokens.GET /api/v1/vehicles/{id}/photos→{ photos: [{ uploadId, sessionId, shotId, kind: "exterior" | "interior" | "detail", view, url, thumbUrl, width, height, verdict: "ok" | "warn" | "retake" | "unchecked", capturedAt }] }: the completed photos of every shoot of the vehicle, one per shot per session (the attempt kept at close, else the latest), newest session first, shots in capture-spec order.urlis a stored, long-lived link;thumbUrla 512 px JPEG of it for grids (made on the first listing after a shoot; null when the photo cannot be decoded).verdictis pre-flight's (warn= advised). Listing refreshes the vehicle'sphotoCountandcoverUrl(the driver-side front corner E1, else the first exterior).GET /api/v1/vehicles/{id}/generations?limit=20&cursor=→{ generations, nextCursor }(passnextCursorback ascursor; a cursor that is not ours → 400invalid_query): the pieces tagged with the vehicle, newest first, each asGET /api/v1/generations/{id}returns it. Tag a piece by sending"vehicleId"withPOST /api/v1/generate; a piece started from a vehicle's capture session (by the phone, or withsessionId) is tagged with it automatically, and revisions keep their parent's.vehicleIdis returned on every generation, and so issetId(the studio set of a photo sent one at a time, §7d; null otherwise), also on the rows ofGET /api/v1/generations.
Vehicles also carry lastStudioSetId and studioAnchor (Nov 7 2026): the newest studio set started a photo at a
time and the anchor of the newest set whose anchor photo succeeded (§7d); null before.
VIN decode — GET /api/v1/vin/{vin}
{ "vin": "1HGCM82633A004352", "valid": true, "reason": null, "checkDigitValid": true,
"year": 2003, "make": "Honda", "model": "Accord", "trim": "EX-V6", "bodyClass": "coupe-hatch",
"raw": { "BodyClass": "Coupe", "VehicleType": "PASSENGER CAR", "Doors": "2", "ErrorCode": "0", … }, "source": "vpic" | "cache" | "none" }
Always 200. A malformed VIN answers valid: false with reason in words ("VINs never contain I, O or Q.") and
nothing else. Facts come from NHTSA's public vPIC database (we send only the VIN) and are kept 90 days; when vPIC is
slow (5 s) or down, source is none and the facts are null: let the user type them. bodyClass maps vPIC's body
description to ours (null when it does not say, e.g. an incomplete chassis): sedan (also wagon), suv (also crossover),
pickup, van (also minivan), coupe-hatch (also hatchback, convertible).
Quote — POST /api/v1/quote
{ usecase, input?, tier?, sessionId?, shots?, skipped? } → { usecase, tier, fromCents, upToCents, balanceCents, enough }.
Resolved exactly as POST /api/v1/generate would (your saved tier when you send none; sessionId fills the photo
fields from that capture session), so upToCents is the amount generate would reserve and fromCents the price when
the first render passes: show it as "from $X · up to $Y". enough = your balance covers upToCents. Nothing is
reserved. Invalid input → 400 invalid_input, as generate.
Delete your account — DELETE /api/v1/me
Signed-in users only (API keys get 403 scope_denied) → { "deleted": true, "organizationDeleted": true }.
Deletes the user's record and sign-in; when nobody else belongs to the workspace (always, until team invites exist)
it closes the workspace at once (its API keys and the card on file go too) and deletes its content (generations,
uploads and outputs, brands, dealers, vehicles, capture sessions) shortly after, in the background. Refused with
409 deletion_needs_support (reason, message to show as is): connected (the workspace has API keys or
dealers of a platform: write to support), suspended (an open payment dispute), in_progress (wait until the pieces
being made finish). The ledger and balance records are kept for billing (Privacy Policy, "How
long we keep it"). Idempotent: repeating it with the same ID token answers 200 again. Any other call with that token
answers 401 account_deleted; sign the user out.
7d. Studio photos, one at a time (Nov 7 2026)
One photo = one job; the set is a view. Each accepted exterior of a capture session is sent as soon as it is
accepted (the driver-side front corner E1 first) and becomes its own car-dealership/studio-set generation of ONE
photo, run, held and charged on its own (the same price per photo as a set of one: POST /api/v1/quote with
perPhoto: true). The first photo sent opens a set (setId, ss_…) and is its anchor: what its capture
record measured (car height and ground line in the frame, camera height, light, exposure gains) is carried to every
later photo of the set, and stored on the vehicle when the anchor photo succeeds. Interiors and details are not
generated: the set view returns them as shot. The whole-set request (POST /api/v1/generate with photos, §4.9 and
§7b) keeps working; the capture SDK's Done screen may keep using it until it moves to this flow.
Send a photo — POST /api/v1/vehicles/{id}/studio-photos (dealer app; API keys, signed-in users)
{ "uploadId": "upl_01M6SJD8HJD1Q9Y7E022NCMA76", "setId": "ss_01M6T649QVP1G6105MEH9HDRQ6", "options": { "wallTone": "warm" } }
uploadId: a completed photo of one of this vehicle's capture sessions (POST /api/v1/vehicles/{id}/shoots), of an exterior shot (E1–E8). An interior or detail → 400not_an_exterior(shotId); unknown (or a phone mask) → 404upload_not_found. Client rule: send a photo only after itsPOST …/uploads/{uploadId}/completeanswered 200. An upload whose complete has not answered yet (bytes still arriving, or complete still running) → 409upload_not_ready: we never wait on an upload, so send it again once complete answered 200 (do not retry the 409 on a timer). An upload complete answered 200 for is never refused with 409: the check reads the same fields complete writes in one step. An upload complete refused (415/422) or that was abandoned → 410upload_expired("Take the shot again"): it will never complete, retake the shot.setId: omit for the first photo (a new set starts, anchored on it); send the answer'ssetIdwith every later photo of the same walk. After an app restart take it fromopenSession.studioSetId(GET /api/v1/vehicles/{id}) orsession.studioSetId(POST …/shoots, reused session) so the walk stays one set. Unknown → 404studio_set_not_found; a set of another vehicle → 409studio_set_mismatch.- Anchor: send
E1first when you can. The first photo of a set is its anchor, whichever shot it is. When a set starts on another exterior (nosetId, the shot is notE1), that photo stays the anchor: anE1sent to the set later is an ordinary photo of it and never replaces the anchor, before or after the anchor photo succeeded. A retake of the anchor shot itself (a new upload of the same shot) replaces it only while the anchor photo has not succeeded. options(first photo only; later photos may repeat the same values or omit them, different ones → 400invalid_body): the studio-set fields other thanphotos(wallTone,numberPlate,windowTint,aspectRatio,signImage,cornerBadge; §4.9). Brand and tier: your defaults, asPOST /api/v1/generate.
→ 202 (the same photo sent again to the same set while it runs or after it succeeded: 200 with that
generation, nothing reserved twice; a photo that failed may be sent again and gets a new generation; a retake of a
shot, a new uploadId, replaces the shot's photo in the set):
{ "id": "147031a4-…", "setId": "ss_01M6T649QVP1G6105MEH9HDRQ6", "status": "queued",
"statusUrl": "https://zivdeo.com/api/v1/generations/147031a4-…", "generation": { "…": "§5, with setId, vehicleId, captureUploadId, captureSessionId" } }
Each photo holds its own amount (402 insufficient_balance as generate) and is linked to the photo's capture session
(listed in capture.completed; no session budget applies to your credentials).
Send a photo with a session token — POST /api/v1/capture-sessions/{id}/studio-photos
The same body and answers, for the capture SDK: a token of THIS session with the generate scope (another session's
id → 403 scope_denied, decided before anything is read), or your backend credentials. The session fixes brand,
tier, webhook and showroom consent as for generate (§7b); options links (signImage, cornerBadge) must be
completed uploads of the session or come from the session's inputDefaults. Each photo's hold counts toward the
session's budgetCents (402 session_budget_exceeded); a set uses ONE of the session's limits.generations (its
first photo), so eight exteriors of one walk fit the default limit of 6. Per-minute limit: the generate bucket (30).
Read a set — GET /api/v1/vehicles/{id}/studio-sets/{setId}
Also GET /api/v1/capture-sessions/{id}/studio-sets/{setId} for a token of that session with generations:read
(after close too; a set of another session → 404). Poll while progress.finishedAt is null; the answer carries
Retry-After: 3 (seconds) while any photo is queued or running and no Retry-After once every one finished.
{
"setId": "ss_01M6T649QVP1G6105MEH9HDRQ6", "vehicleId": "veh_…", "sessionId": "cs_…", "options": { "wallTone": "warm" },
"anchor": { "setId": "ss_…", "shotId": "E1", "uploadId": "upl_…", "sessionId": "cs_…", "generationId": "147031a4-…", "at": "2026-11-07T05:03:10Z",
"measured": { "carHeightFrac": 0.4661, "horizonFrac": 0.3428, "cameraHeightM": 1.07, "light": { "lr": -0.05, "tb": 0.29 }, "gains": [2.14, 1, 1.88], "opticsTrusted": true } },
"photos": [
{ "shotId": "E1", "kind": "exterior", "uploadId": "upl_…", "sessionId": "cs_…", "generationId": "147031a4-…", "status": "succeeded",
"output": "https://firebasestorage.googleapis.com/…", "sourceUrl": "https://firebasestorage.googleapis.com/…", "asShot": false, "issues": [] },
{ "shotId": "E2", "kind": "exterior", "generationId": "67f5db0f-…", "status": "running", "output": null, "asShot": false, "issues": [], "…": "…" },
{ "shotId": "I1", "kind": "interior", "generationId": null, "status": "as_shot", "output": "https://…", "asShot": true, "issues": [], "…": "…" }
],
"progress": { "photos": 2, "done": 1, "asShot": 0, "failed": 0, "startedAt": "…", "finishedAt": null },
"createdAt": "…"
}
photosin capture-spec order (E1–E8,I1–I8,D1–D8): every exterior sent to the set with its generation'sstatus(queued·planning·running·reviewing·succeeded·failed),output(the delivered image once succeeded, else null),asShot(delivered as photographed: the studio could not stage it,issuessay why) andissues(the generation'sreview.issues, or the reason it failed); then the interiors and details of the set's capture session(s) withstatus: "as_shot"andoutput= the photo itself. Exteriors not sent to the set are not listed.options: the set's options, fixed by its first photo (as in the list below);{}when it was sent without any.progresscounts the exteriors sent (interiors are never generated):donesucceeded (of whichasShot),failed;finishedAtis set when every one finished and cleared when a new photo arrives.- Each photo's generation also carries
setIdinGET /api/v1/generations/{id}, in the rows ofGET /api/v1/generationsand inGET /api/v1/vehicles/{id}/generations. anchoris null until the anchor photo succeeds.measured:carHeightFrac= the car's box height / frame height,horizonFrac= where the ground line sits inside the car's box (0 top, 1 bottom),cameraHeightM,light,gains(the camera's white-balance gains), each null when the phone did not measure it; lens-dependent values (horizonFrac,cameraHeightM) are null when the photo's optics record is not trusted (opticsTrusted: false, §7blensLock). The vehicle carries the newest one asstudioAnchorandlastStudioSetId(§7c).
List a vehicle's sets — GET /api/v1/vehicles/{id}/studio-sets
→ { "sets": [{ "setId", "sessionId", "anchor", "progress", "options", "createdAt", "updatedAt" }] }, newest first
(the latest 20).
Price — POST /api/v1/quote { "usecase": "car-dealership/studio-set", "perPhoto": true, "input"?: { "wallTone": "warm" } }
→ { …, "fromCents": 11, "upToCents": 22, "perPhoto": true }: one photo; show "from $0.11 · up to $0.22 a photo" and
multiply by the photos the dealer will send. perPhoto on any other use-case → 400 invalid_body. A capture
session token prices one photo with POST /api/v1/capture-sessions/{id}/quote and input.photos = one upload's URL.
8. Errors
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_body / invalid_input | Schema violation; details[] lists field paths and messages |
| 400 | invalid_revision | Parent is not a finished result of your organization |
| 400 | invalid_query | A query parameter has a value the route does not know (details[]) |
| 400 | not_an_exterior | Studio photos (§7d): the upload is an interior or detail shot; shotId |
| 400 | invalid_range | Upload URL: malformed Content-Range, a size other than the reserved one, a body longer than its range (§7b) |
| 400 | field_not_allowed | A session token sent a field the session fixes (brand, brandId, tier, webhook, showroomConsent, dealer, externalRef, sessionId, vehicleId); fields[] (§7b) |
| 401 | unauthorized | Missing/invalid key or session token |
| 401 | account_deleted | The signed-in user's account was deleted (§7c); sign out |
| 401 | token_expired | Session token past its expiry: get a new one from your backend once and retry (§7a) |
| 401 | session_revoked | A session token whose session was revoked or whose dealer is suspended (409 when your backend asks to mint a token for a revoked session) |
| 401 | upload_url_invalid | The upload URL's key is missing or altered: use the uploadUrl from the reservation |
| 402 | insufficient_balance | requiredCents vs availableCents |
| 402 | session_budget_exceeded | A session token's generation would pass the session's budgetCents; budgetCents, heldCents, remainingCents, requiredCents. Can lift when a running piece of the session fails or settles |
| 402 | dealer_budget_exceeded | A session token's generation would pass its dealer's daily or monthly limit; period, limitCents, spentCents, requiredCents, resetsAt |
| 403 | scope_denied | This credential may not call this route (a session token outside its routes and scopes, §7a) |
| 403 | dealer_suspended | The dealer is suspended |
| 403 | usecase_not_allowed | A capture session named a use-case that is not available for capture, or a session token asked for a use-case outside its session; usecases[] lists them |
| 403 | input_not_in_session | A session token's input links something that is not a completed upload of its session; paths[] |
| 404 | usecase_not_found / brand_not_found / dealer_not_found / session_not_found / vehicle_not_found | |
| 404 | upload_not_found | Unknown upload (or one of another session) |
| 404 | studio_set_not_found | Unknown studio set, or one of another session for a session token (§7d) |
| 409 | studio_set_mismatch | The photo is not of the studio set's vehicle (or, for a set without a vehicle, its capture session) (§7d) |
| 409 | upload_not_ready | Studio photos (§7d): the upload's complete has not answered 200 yet; send it again once it has (never for an upload complete answered 200 for) |
| 409 | deletion_needs_support | DELETE /api/v1/me on a workspace support must close, or with pieces in progress; reason, message (§7c) |
| 409 | vehicle_exists | An active vehicle already has this VIN; vehicle is that vehicle (§7c) |
| 409 | id_taken | The client-chosen id already belongs to another organization; send a new UUID |
| 409 | session_closed | The capture session is closed |
| 409 | upload_incomplete | Complete before every byte arrived; receivedBytes/offset say where to continue |
| 409 | upload_offset_mismatch | A chunk starts past the bytes stored so far; continue at offset |
| 409 | upload_conflict | The shot and attempt are reserved for another file; use the next attempt |
| 409 | upload_completing | Another complete of this upload is still running; retry after Retry-After seconds |
| 410 | session_expired | The capture session is older than 7 days; open a new one |
| 410 | upload_expired | The upload URL expired with its session, its upload completed more than an hour ago, or the upload was refused at complete; reserve again if you still need it. Studio photos (§7d): an upload refused at complete or abandoned (retake the shot) |
| 413 | file_too_large | Over 25 MB declared, or a chunk over 8 MB; maxBytes |
| 415 | unsupported_media_type | Not a JPEG, PNG or WebP photo (HEIC is not accepted yet: send JPEG); sniffed names what the bytes are (or null) |
| 422 | checksum_mismatch | The bytes do not match the declared CRC32C; they were discarded, send the file again from 0 |
| 422 | image_unreadable | A damaged photo, or more than 52 megapixels |
| 422 | content_policy | Synchronous calls (e.g. analyze): an AI service's safety filter declined the input |
| 429 | upload_limit_reached | The session has used its uploads (limit) |
| 429 | preflight_limit_reached | The session has checked its pre-flight photos (limit) |
| 429 | generation_limit_reached | The session has started its generations (limit) |
| 429 | rate_limited | Too many requests in this minute (§7b limits); retry after Retry-After seconds |
| 502 | provider_auth / provider_error | Synchronous calls: an AI service refused or failed; retry later |
| 502 | (only with ?wait=true) | The job ran and failed; body has the generation with error |
| 503 | temporarily_unavailable | Capture sessions cannot issue tokens, take uploads or check photos right now; retry after Retry-After seconds |
| 503 | provider_unavailable / provider_quota | Synchronous calls: an AI service is slow or over capacity; retry after Retry-After seconds |
A failed generation (status: "failed") carries errorCode (§5). Besides the codes listed there: reference_unreachable
and reference_invalid (an image link, see §3), revision_source_expired (a revision whose original piece can no
longer be read for editing; generate the piece again) and internal (a failure on our side, such as storage or network;
error is a fixed message; retry). Nothing is charged for a failed generation.
9. Good practice
- Send a client-generated UUID as
"id"in the request body: retries with the same id return the same job and are never charged twice. An id is private to your organization: an id already used by another organization is refused with 409id_taken(pick a fresh UUID). - One vehicle per request. Give 2–4 exterior angles when you have them;
best-anglewill choose. - Keep
metadatasmall and self-sufficient (your stock number, campaign, target channel). - Expect 1.5–4 minutes per flyer under normal load; do not time out earlier than 10 minutes on your side. Jobs that exceed 30 minutes are failed automatically and released.