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.

  1. 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": { … }
    }
    
  2. Either poll GET /api/v1/generations/{id} (a Retry-After: 15 header 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.
  3. 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 402 means 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" }
}
  • url must be https and publicly reachable.
  • metadata (≤ 8 KB JSON) is stored with the job and echoed back verbatim so your handler needs no lookup.
  • Delivery: POST JSON, 10 s timeout, retried with backoff (6 attempts over ~1 h) until you answer 2xx. Deliveries are idempotent by X-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:

FieldTypeNotes
exteriorPhotosstring[] 1–4required. Photos of the car's exterior.
interiorPhotosstring[] 0–3optional; shown as thumbnails as-is
carPoseas-shot | best-angle (default) | re-posebest-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, yearstring, string, integerrequired
trim, exteriorColor, mileagestring, string, integer (odometer reading: printed in miles, "38,210 mi", unless the currency is metric: km)optional
specsstring[] ≤ 5optional chips, e.g. "AWD · Drivetrain", "V8 · Engine"
currencyUSD EUR GBP TRY AED CAD AUDdefaults to the brand's currency
headline, cta, footnotestring ≤ 28 / 40 / 120optional text; sensible defaults
styleReferencestring (image URL)optional: a flyer whose layout language you like
artDirectionauto (default) | classic | a language idQuality 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.
format2:3 (default) 4:5 1:1 9:162:3 ≈ 1696×2528 px

Brand selection on every generate request (top level, next to input):

FieldMeaning
brandId omittedthe 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].

FieldTypeNotes
photosstring[] 2–4required, one photo per car, display order
carsstring[] 2–4required, one line per car, e.g. "2018 Land Rover Range Rover Velar"
pricesstring[] ≤ 4optional, pre-formatted, e.g. "$21,999"
titlestring ≤ 40default Hand-picked for you
subtitlestring ≤ 80optional
ctastring ≤ 40default Ask about these
artDirectionauto (default) | classic | a language idQuality 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, formatas 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.

FieldTypeNotes
sceneauto (default) | showroom | studio-dark | urban-night | coastal-road | mountain-pass | dealership-lot-cleanValue tier: one scene, one image.
artDirectionauto (default) | classic | a scene language idQuality 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).

FieldTypeNotes
artDirection (service offer)auto (default) | classic | a language idQuality 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 idQuality 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), and error names 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 a scene that cannot carry your car with these photos, another scene that can is used and review.issues starts with scene: 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 direction produces 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.status is passed when every staged render passed, review.score the 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, enhancement bright-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, optional photo, cta, artDirection, format 4: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, artDirection panel languages only, format): equal-size pair, labels checked, the customer's car as shot. auto-services/service-offer-ad (headline, offer, detail, validUntil, finePrint, optional photo, cta, artDirection, format): the phone number is the second-largest element; no stock cars.
  • education/enrollment-ad (program, ages, dates, offer, price, detail, optional photo, cta, artDirection, format) and education/class-promo-post (className, schedule, ages, price, detail, optional photo, cta, artDirection, format): no grades, scores or outcomes beyond the given facts.
  • professional-services/quote-card (text 8–220 chars set word for word, attribution, topic, optional portrait photo, cta, artDirection, format) and professional-services/consult-offer-ad (headline, offer, service, detail, deadline, finePrint, optional photo, 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, optional photo, 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" (always outputs[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 of car-dealership/studio-set with one photo; setId (ss_…) names the set it belongs to, captureUploadId / captureSessionId the photo it made, and input.anchor what the set's first photo measured (set by us; never send it). null on every other generation. Read the whole set with GET /api/v1/vehicles/{id}/studio-sets/{setId} instead of collecting the generations yourself. Each photo has its own hold, charge, webhook delivery and review.
  • On the value tier, designed pieces also return the layouts our judge did not pick as role: "shot" with a label starting with alternative; show them to your user or ignore them. On the quality tier outputs is usually a single final.
  • modelId names the Zivdeo model that produced the piece (zivdeo/…); zivdeo/layout-engine means the piece was composed by our layout engine rather than rendered by an image model.
  • status: "failed" → error explains why; nothing was charged. errorCode says 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 in error; 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; error names the photo to add and between which two photos, or the photo to move or replace), null otherwise.
  • When an AI service behind the piece fails, error is a fixed message and errorCode one of provider_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.statusMeaningSuggested handling
passedThe delivered piece passed the reviewer and every code check.Publish.
best_effortNo 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.
composedDrawn 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.
unreviewedThis 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. null when composed or unreviewed.
  • issues: plain-English findings, blockers first ("FACTS: text: expected 555-0100, saw 555-0108", kinds SUBJECT, FACTS, COMPLIANCE, NONSENSE), then advisory lines starting note:. A passed piece carries notes only.
  • attempts: renders checked for this generation (for composed pieces, layouts compared). passed with attempts: 3 means two renders were rejected before this one. You pay for the renders that ran either way.
  • review is null while 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:

  1. 👍 / 👎 under each delivered piece, with reason chips after a 👎 ("Would you post this?").
  2. used: true when 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: false when 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.

FieldMeaning
rating1 (👍) or -1 (👎); "up" / "down" are accepted too
reasonsReason keys from the table below, up to all 9 at once (duplicates are dropped)
noteFree text, up to 280 characters
usedtrue the piece was published, false the dealer decided not to use it
ReasonSuggested chip labelWhen
car-changedNot my carThe car in the piece is not the dealer's car: wheels, grille, colour, trim or angle differ
factsSomething is wrongA price, name, phone or other fact is wrong or missing
pastedLooks pasted onThe car does not sit in the scene
edgesRough edgesRagged or haloed outline
text-smallText too smallHard to read on a phone
crowdedToo crowdedToo much on the piece
wrong-vibeWrong vibeNot the dealer's style
love-itLove itWith a 👍
otherOtherAnything 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 erases used. reasons and note replace the stored value when sent ([] / null clear 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 own reasons / note are 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 feedback on GET /api/v1/generations/{id} and on the list rows: { rating, reasons, note, used, at }, null until anything was sent; rating and used stay null until reported. (The webhook's generation carries the same field; it is normally null at 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_found for an unknown id, 409 not_succeeded for a job that is still running or failed, 400 invalid_body for 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

EndpointPurpose
GET /api/v1/usecasesCatalogue with JSON Schema for every input and current price estimates
GET /api/v1/generations?limit=50Recent 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/creditsBalance 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}/closeCapture sessions and session tokens for phones (§7a)
POST /api/v1/capture-sessions/{id}/uploads, PUT {uploadUrl}, POST …/uploads/{uploadId}/completeResumable photo uploads for a capture session (§7b)
GET /api/v1/capture/specCapture 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}/generationsYour 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/meDelete 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": "…" } }

  • name is 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 of GET /api/v1/brands (and your account page) unless you ask with ?include=dealer. Or send brandId to link one of your saved brands (not both).
  • defaults.inputs are partial inputs per use-case id, checked against that use-case's fields: an unknown use-case, an unknown field or a wrong value → 400 invalid_body with paths like defaults.inputs.car-dealership/studio-set.wallTone.
  • consent defaults: 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 402 dealer_budget_exceeded (period day or month, limitCents, spentCents, requiredCents, resetsAt). Reserved amounts count until the piece settles (then its charge counts) or fails (then nothing does). usage on the dealer shows the current day's and month's cents. Your own API-key generations are never capped. status: active or suspended (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)
vehiclevin 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
usecases1–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[]
shotListdealer-standard (default) · walkaround-quick · exterior-only
brandId, brandnull/omitted → the dealer's brand; "none" → unbranded; an id → that saved brand. brand overrides on top. Fixed for the session
inputDefaultsPartial 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)
tierFixed for the session; omitted → the dealer's default, else your organization's at generate time
budgetCentsRequired, 1–100000: the most this session's generations may reserve and charge in total
ttlSecondsLife 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
capturedByOptional { externalId } (≤ 128): who shoots, in your system (an agent or user id); stored on the session for your audit
externalRefOptional { ref } (≤ 128): your own reference for this vehicle or job; returned with the session
scopesOptional: 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:

RouteWhoAnswer
POST /api/v1/capture-sessions/{id}/tokens { ttlSeconds? }API key / signed-in user200 { 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 session200 { 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}/closea token of this session, or API key / userBody 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 only200 { 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; reason against-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 400 invalid_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 stated usecase must match), else 400 invalid_body with the path. They only confirm what the session already recorded when each was started: generationIds, generations (with their use-cases) and generatedBy: "device" are kept as recorded, whoever closes. Your backend should not generate the same set again when capture.completed carries 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:

RouteScope
GET /api/v1/capture-sessions/{id}, POST /api/v1/capture-sessions/{id}/closeany token of that session
GET /api/v1/capture/specany token
POST /api/v1/capture-sessions/{id}/uploads, POST …/uploads/{uploadId}/completeuploads:write (the upload URL itself needs no token)
POST / GET /api/v1/capture-sessions/{id}/preflightpreflight
POST /api/v1/capture-sessions/{id}/quotequote
POST /api/v1/generategenerate
GET /api/v1/generations/{id}generations:read (generations this session started; any other id 404)
POST /api/v1/generations/{id}/feedbackfeedback (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–E8 exteriors 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–I8 interiors (I3, the instrument cluster, is a detail), D1–D8 details. 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's lens at 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).
  • targetBox is in fractions of the landscape 4:3 frame: car width between minWidth and maxWidth, tyre contact line between groundLineMin and groundLineMax (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): E1 0.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.longSide and jpegQuality are floors for the phone: an app may raise them, never lower them. maxLongSide is our cap when we normalise an upload. Photos whose long side is below minLongSide are accepted and flagged small.
  • 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 by rearmYawDeg (35°; on exterior shots the turn is required, the picture changing by rearmSceneDelta, 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 least vehiclePresenceMin (0.3 of 1; provisional). What each photo measured for these goes in its capture record (rearm, checks.subject). With autoShutter.requireSubjectCentered the 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.pitchGate is "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 within autoPitchMinDeg…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 outside pitchMinDeg…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 within edgeBand of the frame, or covers more than boxCoverMax or less than boxCoverMin of 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 (sideCut of the height on a side, endCut of the width top or bottom, fill within fillMin…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 as checks.subject.liveCut (edges touched), liveFill (the box's share of the frame) and maskAgreed (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 within targetBox.minWidth…maxWidth, the live box with ± this share of the frame width, the pre-fire mask exactly; it replaces the boxCoverMin/boxCoverMax area rule on those shots (cut edges and the mask's fill stay). After the shot the judged width is an advised check boxWidth ("Step closer." / "Step back."), recorded as checks.results[{ code: "boxWidth" }] and checks.subject.liveBoxWidth / maskBoxWidth.
  • measurements switches the device measurements the phone records after the shutter (capture record measurements): subjectMask (the vehicle outline and its companion mask, which pre-flight's fast path and a light pre-flight read), horizon, camera, light and plateCandidate (the plate area that seeds our plate search). A key set to false switches that measurement off; a missing key means on.
  • provisional names 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 } }
  • shotId is a shot of the session's shot list (§7b spec); attempt (1–99, default 1) numbers retakes. Both may instead be sent inside capture (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 → 409 upload_conflict (use the next attempt number).
  • bytes: the exact size of the file you will send, up to 25 MB (413 file_too_large). crc32c: base64 of the big-endian CRC32C of the file (the usual form in resumable upload protocols). contentType is 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 → 400 invalid_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 state source, trigger, zoom, thermal, exposure (durationSeconds, iso, gains, lens, targetOffsetEV, held, releasedForShot) and rearm (elapsedMs, yawDeltaDeg, sceneDelta: how far the phone moved since the previous photo). Device measurements (SDK 0.2) go under measurements (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, confidence 0–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) and plateCandidate (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 no shotId/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 toward limits.uploads but toward its own ceiling, twice that limit (429 upload_limit_reached; every reservation counts, a refused one too); one per photo attempt (the same file again is the same upload, another one 409 upload_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 carries companions.mask { uploadId, url, width, height } (in GET session and capture.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.completed went 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): 429 upload_limit_reached after 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 most chunkBytes (8 MB); every chunk but the last carries at least 256 KiB.
  • Answer: 308 with Range: bytes=0-<last stored byte> (no Range header while nothing is stored) and Upload-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 410 upload_expired.
  • After an interruption: PUT {uploadUrl} with Content-Range: bytes */<total> and an empty body (or GET/HEAD on the URL) answers the same way: continue at Upload-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 → 409 upload_offset_mismatch with offset.
  • 401 upload_url_invalid (key missing or altered), 410 upload_expired (session expired, or the upload was refused at step 3: reserve again), 409 session_closed, 401 session_revoked, 400 invalid_range (malformed Content-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 as originalURL) is the stored photo; use it as a use-case input.
  • small: true: the long side is under output.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). 409 upload_incomplete with receivedBytes/offset when bytes are missing (send the rest, then complete); 422 checksum_mismatch (the stored bytes were discarded: send the whole file again from offset 0 to the same uploadUrl); 415 unsupported_media_type with sniffed (what the bytes are, or null) and 422 image_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: retake when an enforced check failed; else unchecked when a check that can be enforced could not run this time (a service was slow or down: a photo is never called ok without its enforced checks, nor refused for a failure on our side); else advised when only advised checks failed; else ok. verdict says the same as ok · warn · retake · unchecked; upload, expected and view repeat uploadId, shotId and measuredView (the SDK reads those names). Show unchecked as "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) and cut_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's consent.people is block). Car checks run on exterior shots (E1–E8).
  • Fast path. When the photo's capture record carries measurements.subjectMask with confidence ≥ 0.8, the cut, fill and edge checks and the studio decision come from the phone's numbers, with the same rules as on our cutout, and no server cutout is made. The phone sends edgeTouch per 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, obstructed stays unchecked and 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 is unchecked (advisory) for good. Below 0.8 we cut the photo out ourselves. Each check says where its numbers came from (source: device or server), and sources { subject, studio } sums it up. A measurements.plateCandidate picks 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.
  • studio is what the studio set will do with the photo before its review: staged in the studio, or as-shot (interiors, details, a car too small or filling the frame) with studioReasons. It is the studio set's own decision function, not a second one.
  • people comes from a face detector: a person seen from behind is not counted. unchecked lists checks that did not run, and uncheckedReasons says why for each: unavailable (a service was slow or down this time) or deferred (a light request skipped it; see tiers below). off lists 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's limits.preflightPhotos (60; 429 preflight_limit_reached after that): measuring again is free, and so is all of pre-flight (costCents: 0).
  • Tiers. tier: "full" (the default, also for scope: "set") runs every check above. tier: "light" runs the kind and view classifiers and takes the cut, fill and edge checks and the studio decision 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 in unchecked with reason deferred, and the photo's tier is light. The status rules do not change for a deferred check: an advisory one (obstructed, plate_not_found, people while people are only warned about) leaves the photo ok or advised; an enforceable one (placeholder on an imported photo, people when the dealer blocks people) leaves it unchecked. A later full request 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's tier is full. A light request on a photo already checked answers from the stored result. Each photo a light request checks for the first time counts toward limits.preflightPhotos like 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 left unchecked waits 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 complete succeeds 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) carries pending (and the answer a top-level pending, 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 light POST 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 light POST measures it in the request, once: a second request while that one runs answers pending: true.
    • A full request (and scope: "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 toward limits.preflightPhotos once, 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 …/preflight every 3 s while any photo is pending (the top-level pending), 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 (show unchecked and keep polling).
  • 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 unchecked this time.
  • 404 upload_not_found, 409 upload_incomplete (complete it first), 410 upload_expired (an upload refused at complete), 503 temporarily_unavailable when no check could run at all (nothing is counted; keep shooting and ask again later; the SDK marks the photos unchecked).

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); add names 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 is ready with at least one staged photo; the walk-around with 2–12 exterior photos and no gap. uncheckedShots lists the photos not checked yet or unchecked; a readiness that depends on them is ready: null (with ok: true), never false: a failure on our side is never a retake. Use-cases without a pre-flight rule answer ready: 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" } }
  • usecase must be one of the session's (403 usecase_not_allowed).
  • brand, brandId, tier, webhook, showroomConsent, dealer, externalRef, sessionId and vehicleId come from the session; sending any of them → 400 field_not_allowed (fields[]). A session without a brand generates unbranded (never your organization's default brand).
  • The input is the session's inputDefaults for that use-case with the request's input on top. Every capture photo field the input leaves out (photos for the studio set, exteriorPhotos and interiorPhotos for the walk-around) is filled from the dealer's ring: one photo per shot in shot order, the attempt named in shots (else the session's kept upload, else the latest attempt pre-flight did not refuse), leaving out the shots in skipped and every photo pre-flight refused. shots: [{ shotId, uploadId }] and skipped: [{ shotId, reason }] are the close body's (400 invalid_body for 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's inputDefaults (a dealer's wall sign): anything else → 403 input_not_in_session with paths[]. 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 → 409 id_taken.
  • The reservation counts toward the session and its dealer: 402 session_budget_exceeded (budgetCents, heldCents, remainingCents, requiredCents) when it would pass budgetCents; 402 dealer_budget_exceeded when it would pass the dealer's daily or monthly limit (§7a Dealers); 429 generation_limit_reached (limit) after limits.generations (6) generations, final for the session. The session's heldCents and 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=true is ignored. The session records the id (generationIds, generations) with generatedBy: "device" when the generation is started (not at close), and the generation carries sessionId, dealerId and the session's externalRef (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:

RouteRequests per minute
POST /api/v1/capture-sessions/{id}/uploads (reserve)60
POST …/uploads/{uploadId}/complete60
Upload URL: PUT/PATCH chunks, GET/HEAD/bytes */total offset queries300
GET /api/v1/capture/spec30 (cache it; revalidate with If-None-Match)
POST / GET …/preflight60
POST …/quote60
POST /api/v1/generate30
GET /api/v1/generations/{id}120
POST /api/v1/generations/{id}/feedback60

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 400 invalid_body (the message says why). The check digit (position 9) is evaluated, never enforced: checkDigitValid is 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. bodyClass defaults to the decoded one, else suv. 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. q matches title, VIN (spaces ignored), stock number, make and model; pass nextCursor back as cursor for 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, and studioSetId: 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 this setId, so the walk stays one set.
  • PATCH /api/v1/vehicles/{id} any create field plus status → { vehicle }. null clears a field (title: null goes 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; studioSetId as in openSession above, 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 (externalId self, created with your organization's name), use-case car-dealership/studio-set, shot list dealer-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 with POST /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. url is a stored, long-lived link; thumbUrl a 512 px JPEG of it for grids (made on the first listing after a shoot; null when the photo cannot be decoded). verdict is pre-flight's (warn = advised). Listing refreshes the vehicle's photoCount and coverUrl (the driver-side front corner E1, else the first exterior).
  • GET /api/v1/vehicles/{id}/generations?limit=20&cursor= → { generations, nextCursor } (pass nextCursor back as cursor; a cursor that is not ours → 400 invalid_query): the pieces tagged with the vehicle, newest first, each as GET /api/v1/generations/{id} returns it. Tag a piece by sending "vehicleId" with POST /api/v1/generate; a piece started from a vehicle's capture session (by the phone, or with sessionId) is tagged with it automatically, and revisions keep their parent's. vehicleId is returned on every generation, and so is setId (the studio set of a photo sent one at a time, §7d; null otherwise), also on the rows of GET /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 → 400 not_an_exterior (shotId); unknown (or a phone mask) → 404 upload_not_found. Client rule: send a photo only after its POST …/uploads/{uploadId}/complete answered 200. An upload whose complete has not answered yet (bytes still arriving, or complete still running) → 409 upload_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 → 410 upload_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's setId with every later photo of the same walk. After an app restart take it from openSession.studioSetId (GET /api/v1/vehicles/{id}) or session.studioSetId (POST …/shoots, reused session) so the walk stays one set. Unknown → 404 studio_set_not_found; a set of another vehicle → 409 studio_set_mismatch.
  • Anchor: send E1 first when you can. The first photo of a set is its anchor, whichever shot it is. When a set starts on another exterior (no setId, the shot is not E1), that photo stays the anchor: an E1 sent 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 → 400 invalid_body): the studio-set fields other than photos (wallTone, numberPlate, windowTint, aspectRatio, signImage, cornerBadge; §4.9). Brand and tier: your defaults, as POST /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": "…"
}
  • photos in capture-spec order (E1–E8, I1–I8, D1–D8): every exterior sent to the set with its generation's status (queued · planning · running · reviewing · succeeded · failed), output (the delivered image once succeeded, else null), asShot (delivered as photographed: the studio could not stage it, issues say why) and issues (the generation's review.issues, or the reason it failed); then the interiors and details of the set's capture session(s) with status: "as_shot" and output = 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.
  • progress counts the exteriors sent (interiors are never generated): done succeeded (of which asShot), failed; finishedAt is set when every one finished and cleared when a new photo arrives.
  • Each photo's generation also carries setId in GET /api/v1/generations/{id}, in the rows of GET /api/v1/generations and in GET /api/v1/vehicles/{id}/generations.
  • anchor is 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, §7b lensLock). The vehicle carries the newest one as studioAnchor and lastStudioSetId (§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

StatuserrorMeaning
400invalid_body / invalid_inputSchema violation; details[] lists field paths and messages
400invalid_revisionParent is not a finished result of your organization
400invalid_queryA query parameter has a value the route does not know (details[])
400not_an_exteriorStudio photos (§7d): the upload is an interior or detail shot; shotId
400invalid_rangeUpload URL: malformed Content-Range, a size other than the reserved one, a body longer than its range (§7b)
400field_not_allowedA session token sent a field the session fixes (brand, brandId, tier, webhook, showroomConsent, dealer, externalRef, sessionId, vehicleId); fields[] (§7b)
401unauthorizedMissing/invalid key or session token
401account_deletedThe signed-in user's account was deleted (§7c); sign out
401token_expiredSession token past its expiry: get a new one from your backend once and retry (§7a)
401session_revokedA session token whose session was revoked or whose dealer is suspended (409 when your backend asks to mint a token for a revoked session)
401upload_url_invalidThe upload URL's key is missing or altered: use the uploadUrl from the reservation
402insufficient_balancerequiredCents vs availableCents
402session_budget_exceededA 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
402dealer_budget_exceededA session token's generation would pass its dealer's daily or monthly limit; period, limitCents, spentCents, requiredCents, resetsAt
403scope_deniedThis credential may not call this route (a session token outside its routes and scopes, §7a)
403dealer_suspendedThe dealer is suspended
403usecase_not_allowedA 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
403input_not_in_sessionA session token's input links something that is not a completed upload of its session; paths[]
404usecase_not_found / brand_not_found / dealer_not_found / session_not_found / vehicle_not_found
404upload_not_foundUnknown upload (or one of another session)
404studio_set_not_foundUnknown studio set, or one of another session for a session token (§7d)
409studio_set_mismatchThe photo is not of the studio set's vehicle (or, for a set without a vehicle, its capture session) (§7d)
409upload_not_readyStudio 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)
409deletion_needs_supportDELETE /api/v1/me on a workspace support must close, or with pieces in progress; reason, message (§7c)
409vehicle_existsAn active vehicle already has this VIN; vehicle is that vehicle (§7c)
409id_takenThe client-chosen id already belongs to another organization; send a new UUID
409session_closedThe capture session is closed
409upload_incompleteComplete before every byte arrived; receivedBytes/offset say where to continue
409upload_offset_mismatchA chunk starts past the bytes stored so far; continue at offset
409upload_conflictThe shot and attempt are reserved for another file; use the next attempt
409upload_completingAnother complete of this upload is still running; retry after Retry-After seconds
410session_expiredThe capture session is older than 7 days; open a new one
410upload_expiredThe 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)
413file_too_largeOver 25 MB declared, or a chunk over 8 MB; maxBytes
415unsupported_media_typeNot a JPEG, PNG or WebP photo (HEIC is not accepted yet: send JPEG); sniffed names what the bytes are (or null)
422checksum_mismatchThe bytes do not match the declared CRC32C; they were discarded, send the file again from 0
422image_unreadableA damaged photo, or more than 52 megapixels
422content_policySynchronous calls (e.g. analyze): an AI service's safety filter declined the input
429upload_limit_reachedThe session has used its uploads (limit)
429preflight_limit_reachedThe session has checked its pre-flight photos (limit)
429generation_limit_reachedThe session has started its generations (limit)
429rate_limitedToo many requests in this minute (§7b limits); retry after Retry-After seconds
502provider_auth / provider_errorSynchronous 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
503temporarily_unavailableCapture sessions cannot issue tokens, take uploads or check photos right now; retry after Retry-After seconds
503provider_unavailable / provider_quotaSynchronous 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 409 id_taken (pick a fresh UUID).
  • One vehicle per request. Give 2–4 exterior angles when you have them; best-angle will choose.
  • Keep metadata small 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.