API reference
The viewer SDK speaks to one endpoint in your assigned region. A client reads the product line, resolves what its hardware can hold, then downloads the matching build. All of these routes answer on the hostname you are reading now.
Base URL
Requests are served from the single region your workspace sits in. This one is lax1.
https://fennmoor.tech/v1
Authenticate with a workspace key, passed as a bearer token. Keys are scoped to one workspace and one channel. Viewer keys can read products and pull conditioned builds; they cannot publish a source model.
Authorization: Bearer fnm_live_31c7a4e9b02f6d85
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/products |
List the viewable SKUs in a product line for a channel. |
| GET | /v1/products/:sku/variants |
Colourways, materials and sizes derived from one source model. |
| GET | /v1/meshes/:mesh_id |
Download a conditioned mesh and its texture set — the bulk of what we serve. |
| POST | /v1/targets/resolve |
Map a device tier to a format, level of detail and texture budget. |
| POST | /v1/views/ack |
Record that a shopper placed the product in the room. |
List a product line
This decides what the viewer offers. The SDK reads it when a product page opens and again when the app comes back to the foreground; the payload is tiny, and it is the one thing you never want stale.
GET /v1/products?line=ln_atrium_ss26&channel=retail
// 200 OK { "line": "ln_atrium_ss26", "channel": "retail", "products": [ { "sku": "ATR-4180-WAL", "title": "Atrium Lounge Chair, Walnut", "variant_count": 6, "placement": "floor" }, { "sku": "ATR-2210-OAK", "title": "Atrium Side Table, Oak", "variant_count": 4, "placement": "floor" } ] }
Resolve variants
One source model usually ships as several finishes. Variants share geometry where they can and differ only in their texture sets, so a shopper flipping through colourways pulls far less than the first one cost.
GET /v1/products/ATR-4180-WAL/variants
// 200 OK { "sku": "ATR-4180-WAL", "variants": [ { "variant_id": "var_walnut_oatmeal", "label": "Walnut / Oatmeal", "mesh_id": "msh_atr4180_l1_9c2f7a", "size_bytes": 128974848 }, { "variant_id": "var_walnut_slate", "label": "Walnut / Slate", "mesh_id": "msh_atr4180_l1_5e08b3", "size_bytes": 131596288 } ] }
Download a conditioned mesh
A build's address is the hash of its contents, which makes it permanent: ask for the same
mesh_id in a year and the bytes are identical. Devices therefore cache
indefinitely and re-download nothing until a variant is republished under a new address. Range requests are
supported, so a transfer cut short when a shopper walks off Wi-Fi continues from the byte it reached rather
than restarting from zero.
GET /v1/meshes/msh_atr4180_l1_9c2f7a Range: bytes=0-
// 200 OK (or 206 Partial Content) Content-Type: model/vnd.fennmoor.build Content-Length: 128974848 ETag: "9c2f7a" Cache-Control: public, max-age=31536000, immutable
A conditioned SKU with its 2K–4K texture set runs 20–200 MB depending on tier, and a shopper who scrolls a product line pre-caches every finish they pass. Across a season that is several GB to a single handset. Size your workspace by how many product lines go live, not by page views — devices fetch on their own schedule as shoppers browse, so a launch spreads across days rather than arriving at once.
Resolve a device target
Called before the first download so the viewer asks for a build it can actually render. The tier is derived from the GPU family and available memory the SDK reports; you do not send us a device identifier.
POST /v1/targets/resolve Content-Type: application/json { "sku": "ATR-4180-WAL", "platform": "ios", "gpu_family": "apple-a14", "memory_mb": 4096 }
// 200 OK { "format": "usdz", "detail": "l1", "texture_budget_mb": 96, "mesh_id": "msh_atr4180_l1_9c2f7a" }
Acknowledge a placement
Nothing depends on this, but most teams turn it on: it is how the console shows which finishes shoppers actually stand in the room, which is the number worth having before you commit studio time to the next one. It is also the only request that travels in the other direction, and it names nobody.
POST /v1/views/ack Content-Type: application/json { "sku": "ATR-4180-WAL", "variant_id": "var_walnut_slate", "placed": true }
SDK quickstart
TypeScript, for React Native. The native iOS and Android SDKs mirror these calls.
import { Fennmoor } from "@fennmoor/viewer"; const viewer = await Fennmoor.start({ workspaceKey: "fnm_live_31c7a4e9b02f6d85", endpoint: "https://fennmoor.tech", channel: "retail", }); // Warm the builds for what the shopper is looking at; resumes on its own. await viewer.prefetch("ATR-4180-WAL"); if (await viewer.isReady("ATR-4180-WAL")) { await viewer.place("ATR-4180-WAL", { variant: "var_walnut_slate" }); }
Errors
| Code | Meaning | What to do |
|---|---|---|
| 401 | No key sent, a malformed one, or one since retired. | Check the key hasn't been rotated out. A key covers one workspace and one channel only. |
| 403 | The channel may not show this SKU. | Check the product line's channel rules; don't cache the response. |
| 404 | No such SKU or build in this workspace. | Usually a retired finish. Re-read the product line; hashed IDs never change meaning. |
| 429 | Too many resolves from one address. | Wait out Retry-After. The SDK handles this itself, with jitter. |
Current health for this region lives at /status.