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:
shortDescriptionis the compact product blurb for cards, grids, and PDP headers.descriptionis the primary product detail page body copy. The admin Description textarea writes this field, so storefronts should render it as the default long description.metafieldsare 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.
Related
- Collections & pages — group products
- Inventory — stock per variant
- Media uploads — attach product images
- BYO checkout API — recover saved references
- API versioning & deprecation — compatibility and notice policy