Skip to content

Catalog: products & variants

Products are items; each item has zero or more variants. Reads are available on both the public surface (storefront, ACTIVE only) and the merchant surface (all statuses). Writes are merchant-only and role-gated.

A tiny client

Every example uses this helper. Merchant calls add a bearer token; public calls drop it. Browser public calls carry their Origin automatically. For SSR, pass the current request origin into the helper; Domains selects the data. See automatic data selection.

const API = "https://api.litecommerce.io/api/v1";
 
async function api<T>(path: string, init: RequestInit & { token?: string; storefrontOrigin?: string } = {}) {
  const { token, storefrontOrigin, ...rest } = init;
  if (path.startsWith("/public/") && typeof window === "undefined" && !storefrontOrigin) {
    throw new Error("The current storefront request origin is required");
  }
  const res = await fetch(`${API}${path}`, {
    ...rest,
    headers: {
      "x-organization-slug": "your-store",
      "Content-Type": "application/json",
      ...(storefrontOrigin ? { "x-storefront-origin": storefrontOrigin } : {}),
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...rest.headers,
    },
  });
  if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
  return res.json() as Promise<T>;
}

Read products (public)

// List active items (optionally ?search= & ?type= & ?limit=)
const items = await api<Item[]>("/public/items");
 
// One product by slug — 404 if not ACTIVE
const item = await api<Item>("/public/items/trail-pack-38l");

Public reads only return ACTIVE, non-archived items in the requesting tenant. To see DRAFT or archived records, use the merchant surface.

Nested variants and absence

GET /public/items, /public/items/featured, /public/items/search, /public/items/{slug} and /public/collections/{slug}/items return every non-archived variant of each eligible parent in variants, ordered by sortOrder then createdAt. This nested array is complete: it has no cap, truncation or pagination. Item-list paging bounds parent items (the list defaults to 24, maximum 200, with a keyset cursor), never a returned parent's variants.

On a successful, complete parent read, an exact variant reference missing from variants is unavailable for public purchase now. An empty array means the parent has no non-archived variants listed. Neither establishes the cause (archived, unknown, wrong parent or an old identifier) or permanence: an archived variant can be restored under its existing ID. A listed variant can still have insufficient stock; presence is not an inventory guarantee.

A 404, failed, timed-out or incomplete read establishes no variant retirement. Nor does a parent missing from a paged, featured or search result. Fetch the exact parent in the matching tenant/environment before interpreting nested absence. Merchant variant reads can include archived variants and have different visibility rules.

Public item reads (and collection reads) also carry a rating aggregate — ratingAverage (null when there are no published reviews) and ratingCount — so a listing or collection page can render stars without a per-item call. See Product reviews.

Create a product (merchant)

POST /merchant/items — permission: catalog:write. Owner/Admin retain broad access; Staff needs that group.

const created = await api<Item>("/merchant/items", {
  method: "POST",
  token,
  body: JSON.stringify({
    name: "Trail Pack 38L",      // required
    slug: "trail-pack-38l",      // required, unique per tenant
    type: "SALE",                // required: SALE | RENTAL | SERVICE
    status: "DRAFT",             // required: ACTIVE | DRAFT
    priceInCents: 18900,         // required
    // optional:
    compareAtPriceInCents: 22900,
    description: "Built for long weekend hikes and daily carry.",
    shortDescription: "Lightweight 38L hauler.",
    brand: "Summit",
    vendor: "Summit Gear Co.",
    tags: ["packs", "hiking"],
    metafields: { warrantyMonths: 24 },
  }),
});

A duplicate slug returns 409. All prices are integer cents — there are no floats in the API.

Product copy fields

Use the core copy fields before reaching for metafields:

  • shortDescription is the compact product blurb for cards, grids, and PDP headers.
  • description is the primary product detail page body copy. The admin Description textarea writes this field, so storefronts should render it as the default long description.
  • metafields are for structured or tenant-specific details the core schema does not model — ingredients, scent notes, edition numbers, care instructions, dimensions, certifications, and similar extras.

If a tenant needs variant-specific descriptive copy before a first-class variant description field exists, use metafields.description on the variant as the conventional override key and fall back to item.description. That override should be an exception; it should not replace item.description as the default description source.

Metafields

metafields is an untyped JSON object — a free-form key/value bag, available on both items and variants — for anything the core schema doesn't model (warranty length, scent notes, edition numbers).

Because it's untyped, values come back exactly as they were stored; the API doesn't coerce them. A value written as "01" reads back as the string "01", not the number 1 — a numeric-looking field may be a string or a number depending on how it was entered. Coerce on read in your storefront (Number(m.editionNo), String(m.warrantyMonths)) rather than trusting the shape of any given key.

Update, archive, restore

await api(`/merchant/items/${id}`, { method: "PATCH", token,
  body: JSON.stringify({ status: "ACTIVE", priceInCents: 17900 }) });
 
// Soft-delete (idempotent). Sets status ARCHIVED; remembers prior status.
await api(`/merchant/items/${id}/archive`, { method: "POST", token });
 
// Restore returns the item to its pre-archive status.
await api(`/merchant/items/${id}/restore`, { method: "POST", token });

Archiving is reversible by design — restore puts the item back at the status it held before, so an accidental archive never loses the publish state.

Variants

Variants live under their item. sku is unique within the item; a collision returns 409.

// Create
const variant = await api<Variant>(`/merchant/items/${itemId}/variants`, {
  method: "POST",
  token,
  body: JSON.stringify({
    name: "Granite",            // required
    sku: "TP38-GR",             // required, unique within the item
    priceInCents: 18900,        // required
    options: { color: "Granite", size: "38L" }, // optional
  }),
});
 
// Update / archive / restore mirror the item endpoints:
await api(`/merchant/items/${itemId}/variants/${variant.id}`, {
  method: "PATCH", token, body: JSON.stringify({ priceInCents: 17900 }),
});

Reordering

Both items and variants support explicit display order. Send the complete set of non-archived IDs in the desired order; an incomplete list returns 400.

// Use IDs returned by the merchant catalog; include the full non-archived set.
await api("/merchant/items/reorder", { method: "PATCH", token,
  body: JSON.stringify({ orderedIds: [firstItem.id, secondItem.id, thirdItem.id] }) });

Catalog identifiers and retirement

Item, variant and collection IDs are opaque identifiers, even when UUID-shaped. Preserve returned values exactly; do not derive IDs from a name, SKU, slug or a string prefix. Send canonical UUID references from the matching tenant/environment's current catalog wherever the request schema requires them. A UUID passing format validation does not establish existence, parent ownership or current availability.

Ordinary edits to an existing item, variant or collection retain its ID, as do archive and restore. Archive changes availability; it does not create a new identity. An explicit identity migration can replace previously published IDs, so this is not a promise of universal immutability. A slug that now resolves to a different item ID does not prove that it is the same saved selection.

No public old-to-new map exists or is planned. POST /public/items/resolve accepts item slugs only, returning id, slug and composition; it is neither a variant resolver nor an identifier migration map. The shopper must explicitly select a current product/variant or remove an unavailable saved line. Preserve unaffected lines and quantities rather than emptying the whole basket. See saved-basket recovery.

For a validated current selection, read the exact item's collectionIds for pricing previews that accept collection references. Empty means no memberships. GET /public/collections and /public/collections/{slug} provide current public collection identities for browsing; collection names are not a migration map. Repricing can change discounts, and checkout resolves membership and money server-side; old collection IDs never entitle a shopper to an old discount.

2026-09-29 identifier cutover — retrospective record

Record prepared 2026-10-11. This is a retrospective cutover record, not advance notice or the start of a compatibility window.

The Production UUID cutover committed at 2026-09-29 23:14:19.571773 UTC. It replaced 43 item IDs, 12 variant IDs and 13 collection IDs, plus two organization IDs, across the bounded two-organization population. These are aggregate counts, not one tenant's totals. The Production receipt was posted after the cutover.

Saved browser baskets and links can therefore retain old item, variant and collection references even when the browser code has not changed. Non-UUID references are refused by UUID request validation; a UUID-shaped stale reference is not made live by passing that check. Use the current catalog and the explicit recovery flow. No public old-to-new mapping is available, and this record does not restore old IDs or replay a checkout/payment.

The follow-up record reviews the relevant provider/tenant issue and PR records and public changelog. It did not locate an advance notice covering persisted baskets and all three catalog ID families, a compatibility deadline or a published emergency-exception rationale. This is a missing notice record, not proof that no private/admin communication occurred. The reason for the gap is not established. This retrospective does not establish that a tenant was obliged to clear baskets on an unannounced date.

The process correction is to apply the existing API versioning and deprecation policy to identifier replacement and reliance on long-running behavior, including a schema/runtime mismatch. The platform owns replacement/compatibility planning, the public record and tenant notice, and verified publication evidence before treating a notice clock as started; an emergency exception requires its published reason. Tenant implementers own recovery UI and integration verification against the announced contract. This record selects no new notice duration or exception.