Projects
Manage 3D projects and configurations. Project types include configurator, viewer, cpq, ar-preview, and embedded.
List All Projects
const response = await fetch(BASE_URL + '/projects', {
headers: { 'Authorization': 'Bearer ' + accessToken }
});
const data = await response.json();
// {
// "success": true,
// "projects": [
// {
// "id": "proj_123",
// "name": "Modern Office Design",
// "type": "configurator",
// "thumbnail": "https://...",
// "createdAt": "2026-02-25T10:00:00Z",
// "updatedAt": "2026-03-01T14:30:00Z"
// }
// ]
// }Create Project
const response = await fetch(BASE_URL + '/projects', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'New Product Configuration',
type: 'configurator' // 'configurator' | 'viewer' | 'cpq' | 'ar-preview' | 'embedded'
})
});
const data = await response.json();
// { "success": true, "project": { ... } }Get Project
Large projects are stored gzipped, so this endpoint returns one of two payload shapes. There is no discriminating response header and no ?format= parameter — you must feature-detect which shape you received.
// Minimal gzip-base64 helper used below. DecompressionStream is available in
// modern browsers and Deno, but NOT in Hermes / React Native — use pako there.
async function gunzipBase64(b64) {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
return await new Response(stream).text();
}
const response = await fetch(BASE_URL + '/projects/proj_123', {
headers: { 'Authorization': 'Bearer ' + accessToken }
});
const data = await response.json();
const payload = data.project.data; // scene always lives under project.data
// Feature-detect the shape — there is no header or ?format= to branch on.
let sceneData;
if (payload.sceneDataCompressed) {
// Compressed variant: gzip -> base64. sceneDataEncoding is 'gzip-base64'.
sceneData = JSON.parse(await gunzipBase64(payload.sceneDataCompressed));
} else {
// Uncompressed variant: the scene is already expanded.
sceneData = payload.sceneData;
}
// CAREFUL: models/camera are NOT always top-level. In the compressed variant
// project.data carries ONLY sceneDataCompressed + sceneDataEncoding, so
// payload.models and payload.camera are undefined — read them from the
// decompressed scene and fall back to the top level for the other variant.
const models = sceneData?.models ?? payload.models ?? [];
const camera = sceneData?.camera ?? payload.camera;Large-Project Response Variants
Over raw HTTP, GET /projects/:id returns exactly one of these two shapes. Both nest the scene under project.data:
- • project.data.sceneData — the expanded scene, alongside project.data.models and project.data.camera.
- • project.data.sceneDataCompressed + project.data.sceneDataEncoding ("gzip-base64") — gunzip and JSON.parse it to get the same object.
There is no third, top-level project.sceneData variant. That field is synthesized by the official SDK, which decompresses and normalizes both shapes for you — it is never produced by the raw HTTP endpoint, so do not branch on it.
Deduped materials travel in sceneData.materialDictionary. Variants that share a material are stored as a _materialRef pointing into that dictionary instead of repeating the PBR data. Resolve refs against it before rendering — a variant with a ref you cannot resolve has no material at all. Between April and August 2026 the uncompressed branch of this response omitted the dictionary, so a headless consumer received unresolvable refs; it is now returned in both branches and pinned by a server test.
This response is Cache-Control: no-cache (it was briefly max-age=30). The row is written by several actors, so a cached body can be older than a change you just made. It still carries an ETag — revalidate with If-None-Match rather than caching by age.
The models[] entry shape differs from the public share endpoint. Here each entry is the raw stored SerializedModel: transforms are [x, y, z] arrays, and it carries visible and name but no url. The share endpoint normalizes transforms to { x, y, z } objects and adds a pre-signed url — see Project Sharing.
Update Project
A data payload is a full-scene overwrite, not a deep merge: it replaces the stored scene with whatever you send. That matters more than it used to, because the project row now has several writers — your integration, the owner’s editor, the in-app AI Assistant, approved AI changes, agent workflows, and MCP clients. A write built from a stale read silently erases everything that landed in between.
Optimistic concurrency — baseUpdatedAt (opt-in, v3.7)
Send the updatedAt you read with the project and the server refuses a stale write with HTTP 409 instead of applying it. Omit it and you get the historical last-write-wins behaviour, so existing integrations are unaffected.
It is a control field: stripped before the merge, never persisted on the row. The check fails open on anything it cannot compare (a legacy row with no updatedAt, an upsert), so it can never block saving outright. A partial update that carries no data — a rename, for example — cannot clobber scene data and does not need it.
const current = await (await fetch(BASE_URL + '/projects/proj_123', {
headers: { 'Authorization': 'Bearer ' + accessToken }
})).json();
const response = await fetch(BASE_URL + '/projects/proj_123', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Updated Project Name',
data: { settings: { autoRotate: true } },
// Optional. Refuse the write if anything else touched the row since the read.
baseUpdatedAt: current.project.updatedAt
})
});
if (response.status === 409) {
const conflict = await response.json();
// { "success": false, "conflict": true,
// "error": "This project was changed outside this editor session",
// "serverUpdatedAt": "2026-08-19T14:31:07.412Z" }
// Re-read the project and re-apply your change on top of the newer state.
}The same contract applies to the sendBeacon variant POST /projects/:id/update, with one difference: a page being unloaded cannot react to a response, so a stale beacon is dropped server-side (bare 409, no body) rather than applied.
sourceTemplateUserId is server-owned on both write paths. It is set only when a project is instantiated from a template; a client-supplied value is ignored.
Duplicate Project
Creates an independent copy by deep-cloning the project's entire sceneData — all 3D models (with nested groups and parts), every option block and variant (hotspots, scenery, materials, …), the full pricing structure (price groups, 1D/2D tables, unique-price blocks, variables, and all SKU maps), the complete form, animations, conditional logic, and project settings. Asset-library models, materials, and textures are reused by reference (no extra storage cost). Per-project uploaded models, the AR USDZ file, and the thumbnail are copied to the new project's storage path and their stored paths rewritten so the copy is independent of the source. Share settings, WooCommerce / Shopify product links, and saved configurations are not carried over.
Plan gate: requires Pro or Enterprise. Starter callers receive 403.
const response = await fetch(BASE_URL + '/projects/proj_123/clone', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'My Configurator Copy' // optional — defaults to "{source name} Copy"
})
});
const data = await response.json();
// {
// "success": true,
// "project": { "id": "proj_new", "name": "My Configurator Copy", "type": "configurator", ... },
// "warnings": { "failedModelCopies": [...], "message": "..." } // present only on partial failure
// }Delete Project
const response = await fetch(BASE_URL + '/projects/proj_123', {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});