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.

const API = "https://api.litecommerce.io/api/v1";
 
async function api<T>(path: string, init: RequestInit & { token?: string } = {}) {
  const { token, ...rest } = init;
  const res = await fetch(`${API}${path}`, {
    ...rest,
    headers: {
      "x-organization-slug": "your-store",
      "Content-Type": "application/json",
      ...(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 ever return ACTIVE items. To see DRAFT or archived records, use the merchant list below.

Public item reads (and collection reads) also carry a rating aggregateratingAverage (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.

await api("/merchant/items/reorder", { method: "PATCH", token,
  body: JSON.stringify({ orderedIds: ["id-a", "id-b", "id-c"] }) });