Viewrium Docs

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:

FieldSourceWhat happens
image_urlsOne or more product image URLsImages -> analysis -> catalog images and (optionally) a GLB
video_urlA product video URLFrames extracted, the useful ones selected, then the same path
source_urlA product page URLThe page is read for images and metadata, then the same path
model_urlA hosted 3D model file URLConverted to GLB and stored - nothing is generated
resume_tokenA token from a parked requestCommits that quote and starts the run
image_pathsUploads-bucket object pathsThe 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: false marks 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_token expires (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 is missing_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 folds status together with published and whether any asset is still in flight, so it also takes generating and published. 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 until display_status leaves generating. 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:

Python
from viewrium import ProductUpdateRequest

client.products.update(product.id, ProductUpdateRequest(custom_id="sku-oak-chair"))
TypeScript
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.update stores dimensions without touching the GLB. products.rescale is 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.

On this page