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.