How generation works
The single product entrypoint, source types, the park/commit gate, the model verdict, and the product lifecycle.
Everything that produces a product goes through one endpoint: POST /v1/products, exposed as
products.create in both SDKs. You give it exactly one source, it answers a quote, and you
commit that quote.
Sources
Set exactly one of these fields on the create request:
| Field | Source | What happens |
|---|---|---|
image_urls | One or more product image URLs | Images -> analysis -> catalog images and (optionally) a GLB |
video_url | A product video URL | Frames extracted, the useful ones selected, then the same path |
source_url | A product page URL | The page is read for images and metadata, then the same path |
model_url | A hosted 3D model file URL | Converted to GLB and stored - nothing is generated |
resume_token | A token from a parked request | Commits that quote and starts the run |
image_paths | Uploads-bucket object paths | The dashboard's direct-to-storage upload; API callers use image_urls |
image_urls, video_url, source_url and model_url are all URLs - Viewrium fetches them,
so they must be publicly reachable. There is no binary file upload on the public API; host your
assets and pass their URLs.
Everything the customer owns rides in draft: title, description, price, currency,
custom_id, tags, metadata, wall_mounted, and dimensions (real-world height / width /
depth, with an optional unit). What to produce rides in order (images, model - either
half may be ordered alone), and the per-run answers ride in overrides.
Quote first, then commit
A fresh create always parks. The analysis runs synchronously and nothing is written - no
product row exists, so nothing can be orphaned. You get HTTP 200 and a ProductParked:
{
"status": "parked",
"resume_token": "...",
"expires_at": "2026-09-23T12:34:56Z",
"form": "seating",
"title": "Oak dining chair",
"flags": { "transparent": false, "geometry_risk": false, "source_quality": "ok", "reasons": [] },
"plan": [{ "view": "front", "kind": "wanted", "status": "planned", "deliverable": true, "...": "..." }],
"model": { "ordered": true, "allowed": true, "blocked_by": [], "views": ["front", "back"], "unseen_views": ["back"] },
"cost": { "images": 4, "image_credits": 4, "model_credits": 10, "total_credits": 14 }
}plan- every page position that will be produced, in page order, with the route and the evidence behind each one.deliverable: falsemarks a row the 3D model needs and nobody buys.flags- the analysis's structured read of the product, with its reasons in its own words. Show them verbatim.model- the models layer's verdict (see below).cost- what committing will cost, in credits (1 credit = $0.50: a photo is 1 credit, a model 10).
Post resume_token back through products.create to commit. That returns HTTP 202 with the
created product and starts the run. The quote is immutable: a resume may still edit the
draft, but stating a different order, or any overrides at all, is a 400 - changing what
gets generated means a new request.
resume_tokenexpires (expires_at, 7 days) - the pre-fetched source URLs behind it are short-lived, so resume promptly rather than storing tokens for later.
The one exception is model_url: a finished GLB has nothing to analyze and nothing to price, so it
commits straight away (HTTP 202) and never parks.
The model verdict
model on a parked response is a ModelPlan:
{
"ordered": true,
"allowed": false,
"blocked_by": ["unseen_views"],
"views": ["front", "back", "side", "top"],
"unseen_views": ["back", "top"]
}views- the viewpoints the model will be built from, in submission order.unseen_views- the viewpoints that would be invented: nothing in the photographs shows, pins or implies them. They are always listed, whether or not they refuse the model.blocked_by- why a model is refused, if it is. The vocabulary ismissing_3d_view,transparent,geometry_risk,texture_risk,unseen_views.allowed- whether the commit will build one.
One invented view is reported; two or more refuse the model. The back of a sofa photographed
only from the front is common and tolerable, so it is named and built anyway. A model most of which
is guesswork is a guess at the product, so it is refused as unseen_views.
The refusal binds API and SDK callers only. Take the model anyway by passing
overrides.force_model on the create, or by turning Always generate models on for your
organization in Settings -> Advanced - the per-run override wins, the org preference stands
when you send nothing. (The dashboard always forces, shows every flag and invented view on the
Summary, and treats pressing Generate as the approval - which is why you never see a refusal
there.)
overrides.generate_unseen is the same shape for the images half: an unseen catalog position is
skipped unless you ask for it to be generated, and the org's Always generate images preference
is the standing answer.
The product lifecycle
products.create (the commit) is asynchronous: it returns quickly with the created product, then
the run produces the images and the model in the background.
A product carries two statuses:
status- the stored review lifecycle:awaiting_review,needs_attention,ready_to_publish,complete.display_status- what a surface shows. It foldsstatustogether withpublishedand whether any asset is still in flight, so it also takesgeneratingandpublished. This is the one to branch on, and the one the catalog filter takes.
Each asset carries its own status (planned, generating, ready, failed, skipped) and its
own review_status (pending, accepted, rejected - one way out of pending). A failed model
is not a failed product: the run completed, and model_status on the payload says what
happened.
You learn about the ending in one of two ways:
- Polling - call
products.retrieve(id)on an interval untildisplay_statusleavesgenerating. Simple, good for scripts. - Webhooks - register an endpoint and Viewrium calls it on
product.completed/product.failed. Preferred for production. See Webhooks.
Publishing
Nothing is public until you publish it. products.update(id, { published: true }) toggles one
product; products.publish({ ids, published }) toggles a batch. A publishable (pk_) key - and so
the embed - only ever sees a published product with a ready model.
Your own id: custom_id
custom_id is your external identifier for a product (a SKU, say). It is unique within your
organization: a second product with the same custom_id returns 409 Conflict.
A product created without one takes its own product id as its custom_id, so the key an embed
is addressed by always exists and is unique by construction. It is yours to overwrite afterward:
from viewrium import ProductUpdateRequest
client.products.update(product.id, ProductUpdateRequest(custom_id="sku-oak-chair"))await client.products.update(product.id, { custom_id: "sku-oak-chair" });Products created before this default was introduced keep their empty custom_id - it was not
backfilled. Look products up by yours with products.list({ custom_id: "sku-oak-chair" }).
Real-world scale and AR
Furniture has to appear at true size in AR. Provide draft.dimensions (any one to three axes) on
create, or apply them afterward with products.rescale.
Dimensions are always stored and returned in centimeters. Send unit (mm, cm, m, in,
ft) to give them in something else - Viewrium converts at the boundary and remembers the unit as
display_unit, purely so a dashboard can show your numbers back in it. It never changes what is
stored.
A partial set is fine: every axis you supply is applied to the geometry, measured on the model's
own footprint rather than the world axes, and the axes you leave out keep the model's proportions.
The response carries the ACHIEVED height/width/depth - including the ones you did not
specify.
products.updatestores dimensions without touching the GLB.products.rescaleis what re-applies them to the geometry.
Which way round is it?
Width is side to side and depth is front to back, which only means something once you know which way the model faces. Viewrium works that out from your product photos, so you never have to say - a bed can be wider than it is deep or the other way round, and only the real product settles it.
The one case with no photo to look at is a 3D file you uploaded directly. There,
products.rescale accepts width_axis ("x" or "z") to name the axis your width applies to;
the other one takes the depth. Leave it unset otherwise - a wrong value overrides a correct
decision.