Product catalog
Model products and services with UNECE units, presentations, variants, supplier offers and immutable document snapshots.
The product catalog models what you sell and, separately, how it is packaged,
identified, stocked and purchased. Start with a fact_test_ key and grant only
products:read and/or products:write as needed.
Product or service
Every item has an explicit item_kind:
productcan manage decimal stock and have stock-owning variants.servicealways hasmanage_stock: false, stock0.0000and no low-stock threshold. A service cannot have a variant with its own stock.
The base unit is one of the closed UNECE catalog: C62, KGM, GRM, LTR,
MLT, MTR, MTK, HUR or DAY. Quantities and stock use up to four
decimals; conversion factors use up to six. You cannot change the base unit once
existing stock, presentations, variants, offers or stock history would be
reinterpreted.
{
"name": "Extra-virgin olive oil",
"sku": "EVOO-BULK",
"price": "8.75",
"item_kind": "product",
"base_unit": "LTR",
"manage_stock": true,
"stock": "125.5000",
"low_stock_threshold": "20.0000",
"specifications": { "origin": "Jaén", "harvest": 2026 }
}Create and update semantics
POST /v1/products requires name and price. If external_id already
identifies a product in the same company, creation is an idempotent upsert
instead of a duplicate. sku is also unique per company, but a collision with
another product is an error rather than an upsert.
PUT /v1/products/{product} has partial semantics: every omitted field
keeps its current value. Two fields have explicit replacement behavior:
specificationsreplaces the complete key-value object;nullclears it. Non-empty JSON lists are rejected, while{}and[]remain compatible empty representations.stockis an absolute quantity, not a delta.nullsets it to zero. A real difference records a manual stock movement; sending the current value records nothing. Use the dedicated stock endpoint withincreaseordecreasefor explicit deltas.
stock and low_stock_threshold accept up to four decimals. Product
metadata keeps its backwards-compatible boundary: up to 50 keys, scalar
values are coerced to strings, and JSON lists are accepted and persisted with
numeric keys. This is intentionally different from specifications.
Presentations
A presentation is a commercial way of selling a product; it never owns stock.
| Mode | Meaning |
|---|---|
fixed | base quantity = commercial quantity × conversion_factor. A box of 12 units uses factor 12.000000. |
variable_measure | The nominal measure is informative. Each document line must confirm its real base quantity. |
POST /v1/products/{product}/presentations{
"name": "Box of 12",
"mode": "fixed",
"unit": "C62",
"conversion_factor": "12.000000",
"active": true
}Use the collection GET/POST endpoint and the item PUT/DELETE endpoint.
An inactive presentation cannot be selected for a new line, but old documents
continue to render from their snapshot.
Variants
A variant carries its own commercial identity: name, optional SKU/barcode, price and cost overrides, and optionally its own stock.
{
"name": "Blue / M",
"sku": "TSHIRT-BLU-M",
"price_override": "24.5000",
"manage_stock": true,
"stock": "8.5000",
"active": true
}When manage_stock is true, movements affect only the variant; otherwise they
fall back to the base product while preserving the variant identity in the line
and stock ledger. A presentation can be combined with a variant: the variant
chooses the stock target and the presentation converts the quantity.
Configurable options and sellable combinations
Option groups model choices a buyer selects — finish, flavour or service
level — rather than informational specifications. Each group contains
single-select values; a value can add a price_adjustment per commercial unit.
Inactive groups/values stay readable so historical line snapshots keep their
labels, but cannot be selected for a new line.
Commercial configurations combine an optional variant, an optional
presentation and a canonical set of option values. Each configuration has a
stable signature, can define a final price per commercial unit and can be
active or inactive.
catalog_availability_mode controls what is sellable:
| Mode | Availability |
|---|---|
open | The Cartesian product of active axes is sellable. A configuration only restricts availability when restricts_availability: true; it constrains its named variant and treats omitted presentation/options as wildcards. |
closed | Active configurations are the exact allow-list. With no active configuration, nothing is sellable. |
Creation defaults to open; omitting the field on update preserves the current
mode. Configuration rows with restricts_availability: false can still provide
identity or an exact price without narrowing an open catalog.
Use GET /v1/products/{product}/options and
GET /v1/products/{product}/configurations to inspect the catalog. Both use
products:read and cursor pagination. Product detail can include both with
include=configurable_catalog.
POST /v1/products/{product}/resolve-selection is also a read, despite POST:
it accepts a nested partial selection and returns resolved, incomplete,
ambiguous or incompatible, plus the remaining compatible axes and pending
groups. Duplicate values from the same group are an invalid request.
Before a write narrows availability, call
POST /v1/products/{product}/configurations/impact-preview with
products:write. It persists nothing and returns the affected combinations,
retired negotiated prices and an impact_token that fingerprints that exact
catalog state. Send the token back on the destructive update; if the catalog or
affected prices changed meanwhile, request a new preview.
Stock ledger
GET /v1/products/{product}/stock-movements exposes the append-only stock
ledger from newest to oldest. Each row has the signed base-unit delta, the
stock_after balance calculated from the complete ledger, reason, source
document, acting user and managing variant. Cursor pagination uses limit and
starting_after; direction=in|out filters visible rows without changing the
historical stock_after values.
Supplier offers
A supplier offer represents purchase terms without changing the sales price. It points to the product or one variant and records the supplier, purchase unit, conversion factor, availability, cost and lead time.
{
"supplier_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a10",
"variant_id": null,
"supplier_sku": "SUP-EVOO-20L",
"purchase_unit": "LTR",
"conversion_factor": "1.000000",
"unit_cost": "5.1250",
"availability": "available",
"minimum_quantity": "20.0000",
"lead_time_days": 3,
"preferred": true,
"active": true
}available requires a non-negative unit_cost. unavailable, unknown,
seasonal and store_dependent require unit_cost: null. Only one active offer
can be preferred for a product/variant target; use the dedicated preferred
action to switch it atomically.
When a purchase invoice line buys against an offer, purchase_measurement
records the packages received and the unit the cost is quoted in
(offer_unit, base_unit or presentation_unit); the server derives the
billed quantity and freezes the unit cost. unit_price omitted on a catalog
line means "cost not confirmed": the API applies the offer cost or rejects the
line when the supplier has no active offer. See
Measured purchase lines.
Documents keep snapshots
Catalog-backed document lines return the selected product_id, variant_id,
presentation_id, commercial configuration/signature and option values,
item_kind, commercial unit, base unit, conversion factor, base quantity,
price source/semantics, option adjustments, price-list identity and price unit.
These are snapshots: changing or retiring the live catalog does not rewrite
issued or historical documents.
For a variable_measure presentation, send confirmed_base_quantity when
building the line. Let the API derive totals and stock movement quantities.
Permissions, deletion and MCP
- Reads use
products:read; creates, updates and the new subresource deletes useproducts:write. Deleting the base product usesproducts:delete. - Presentation, variant and supplier-offer deletes are marked
x-irreversible: truein OpenAPI. - Products referenced by a live price-list item or supplier offer cannot be hard-deleted. Remove the active reference first; document history is safe in snapshots.
- The Product MCP domain now has 38 tools. In addition to the existing 13 presentation/variant/supplier-offer tools, four new reads expose options, configurations, stock movements and selection resolution.
See Price lists, Scopes & irreversibility and the exact schemas in the API Reference.
Document descriptions and reports
Document lines support nullable additional_description up to 5000 characters alongside the main description. It is available on sales invoices and correctives, quotes, proformas, delivery notes, purchase invoices and recurring templates. Keep it separate from quantities, prices, tax fields and catalog identifiers; adding detail does not change the amount calculation. Read GET /v1/products/low-stock-report for the collection and total_count, and GET /v1/products/{product}/sales-analytics for sales metrics; these responses are not single product resources. See their complete examples in the API Reference.