Skip to main content

Catalog

Explore the API
Launch the OpenAPI Explorer to browse the live REST specs, inspect request and response schemas, and execute calls against your environment with an API key.

The catalog module exposes both command handlers and REST endpoints under /api/catalog/* (products, variants, prices, price kinds, option schemas, offers, categories, tags, product media, and dictionaries). The commands documented below remain the source of truth for validations and undo metadata, so you can safely script imports, seed data, or build bespoke HTTP wrappers even as new HTTP routes come online.

REST endpoints

Every REST route described below lives in packages/core/src/modules/catalog/api. They share a few guarantees:

  • Authentication and RBAC checks are enforced via each route's metadata export.
  • Organization/tenant scope is derived from the caller, so payloads never cross tenants.
  • CRUD handlers reuse the same Zod schemas as the command bus, so keeping one payload up to date keeps both access paths in sync.
  • Listing endpoints default to { items, total, page, pageSize, totalPages } responses and accept both cf_<key> and cf:<key> filters emitted by the custom-field engine.

/api/catalog/products

PermissionsGET requires catalog.products.view; POST/PUT/DELETE require catalog.products.manage.

Filters

ParamTypePurpose
page, pageSizenumberPagination with a max of 100 rows per page.
searchstringCase-insensitive match on title, subtitle, SKU, handle, or description.
status, isActive, configurablestringFilter by workflow status, activation, or configurability flags.
productTypeenumNarrow the list to one of the CATALOG_PRODUCT_TYPES.
channelId, channelIds, offerIdstringRestrict products bound to a specific channel/offer.
categoryIds, tagIdsstringComma-separated IDs for taxonomy filters.
userId, userGroupId, customerId, customerGroupIdstringScope best-price resolution to a specific audience.
quantity, priceDatenumber/stringProvide context for the pricing service (quantity tiers + date-based prices).
sortField, sortDirstringSort by title, SKU, createdAt, or updatedAt.
withDeletedbooleanInclude soft-deleted products.
customFieldsetstringEnsure the backend interprets cf_* filters using the selected fieldset.

The resolver decorates each item with:

  • offers: channel-level offers resolved from CatalogOffer.
  • pricing: the return value of catalogPricingService.resolvePrice, including the matching price kind, amounts, and scope (variant/offer/channel/audience IDs).
  • Flattened custom-field values (standard keys only — raw cf: keys are stripped).

MutationsPOST and PUT accept exactly the shapes validated by productCreateSchema/productUpdateSchema. DELETE requires a payload with { id } and delegates to catalog.products.delete, performing a soft delete.

/api/catalog/variants

PermissionsGET requires catalog.products.view; writes require catalog.variants.manage.

Filters

ParamTypePurpose
page, pageSizenumberPagination (≤100 per page).
searchstringMatch variant name, sku, or barcode.
productId, skustringFilter to a specific product or exact SKU.
isActive, isDefault, withDeletedstring/booleanToggle core flags or include soft-deleted rows.
sortField, sortDirstringSort by name, sku, createdAt, or updatedAt.
cf_*stringApply custom-field filters (scoped to the caller's tenant).

Responses include option values, dimensions, metadata, and flattened custom fields. POST/PUT payloads must satisfy variantCreateSchema/variantUpdateSchema; DELETE accepts { id } and calls catalog.variants.delete.

/api/catalog/prices

PermissionsGET requires catalog.products.view; mutations require catalog.pricing.manage.

Filters

ParamTypePurpose
page, pageSizenumberPagination.
productId, variantId, offerIdstringScope prices tied to a specific record.
channelId, currencyCodestringFilter by channel or ISO currency code.
priceKindId, kindstringRestrict by price kind or price kind code.
userId, userGroupId, customerId, customerGroupIdstringFilter by personalized price audiences.
withDeletedbooleanInclude rows even if the associated product/variant was soft deleted.
sortField, sortDirstringSort by currency, price kind, quantity band, createdAt, or updatedAt.
cf_*stringCustom-field filters/ordering helpers.

POST/PUT reuse priceCreateSchema/priceUpdateSchema so the HTTP route mirrors the command bus. DELETE forwards { id } to catalog.prices.delete. Responses expose raw catalog_product_price columns plus any associated custom fields.

/api/catalog/price-kinds

Permissions — all methods require catalog.settings.manage.

Filters

ParamTypePurpose
page, pageSizenumberPagination.
searchstringCase-insensitive match on code or title.
isPromotion, isActivestringFilter by promotion/activation flags (true/false).
sortField, sortDirstringSort by code, title, display mode, currency, createdAt, or updatedAt.

POST and PUT translate inputs through priceKindCreateSchema/priceKindUpdateSchema and return { id }/{ ok: true }. DELETE expects { id } and soft deletes the price kind.

/api/catalog/option-schemas

PermissionsGET requires catalog.products.view; writes require catalog.settings.manage.

Filters

ParamTypePurpose
page, pageSizenumberPagination.
idstringFetch a specific schema template.
searchstringMatch name or description.
isActive, withDeletedstring/booleanFilter by activation or include soft-deleted templates.
cf_*stringApply custom-field filters stored on option schemas.

Responses surface the saved schema JSON alongside metadata. POST/PUT wrap optionSchemaTemplateCreateSchema/optionSchemaTemplateUpdateSchema; DELETE forwards { id } to catalog.optionSchemas.delete.

/api/catalog/offers

Permissions — all methods require sales.channels.manage because offers tie catalog products to sales channels.

Filters

ParamTypePurpose
page, pageSizenumberPagination.
productId, idstringLimit offers for a given product or offer.
channelId, channelIdsstringFilter by one channel or a comma-separated list.
searchstringMatch offer title/description.
isActive, withDeletedstring/booleanRestrict by activation or include soft-deleted offers.
sortField, sortDirstringSort by title, createdAt, or updatedAt.

List responses embed:

  • product: a lightweight snapshot (id, title, default media, SKU).
  • prices: prices attached directly to the offer (with price kind metadata).
  • productChannelPrice: fallback product-level price for the requested channel.
  • Flattened custom-field values.

POST/PUT feed offerCreateSchema/offerUpdateSchema; DELETE resolves { id } through catalog.offers.delete.

/api/catalog/categories

PermissionsGET requires catalog.categories.view; writes require catalog.categories.manage.

GET accepts:

ParamTypePurpose
viewenum (manage/tree)manage returns paginated rows flattened from the tree; tree returns nested nodes.
page, pageSizenumberPagination (manage view only).
searchstringMatch category names or breadcrumb labels.
statusenum (all/active/inactive)Filter by activation flag.
idsstringComma-separated category IDs to focus on specific nodes.

manage view rows include hierarchy metadata (depth, parent, child counts, breadcrumb labels) plus any custom-field values resolved via loadCustomFieldValues. tree view collapses the same hierarchy into nested children[].

POST, PUT, and DELETE route payloads through categoryCreateSchema, categoryUpdateSchema, and catalog.categories.delete respectively.

/api/catalog/tags

PermissionsGET requires catalog.products.view.

Query parameters: search, page, and pageSize. The handler enforces organization scope and returns { items, total }, where each item exposes id, label, slug, createdAt, and updatedAt.

/api/catalog/product-media

PermissionsGET requires catalog.products.view.

Query: productId (UUID, required). The route validates scope, loads attachment rows for the product, and returns { items: [{ id, fileName, url, thumbnailUrl }] }. Thumbnails are precomputed via buildAttachmentImageUrl for UI galleries.

/api/catalog/dictionaries/:key

PermissionsGET requires catalog.products.manage.

Path parameter :key accepts currency, currencies, unit, units, or measurement_units (aliases are normalized). The route resolves the matching dictionary for the caller's organization/tenant and returns { id, entries: [{ id, value, label, color, icon }] }. Use it to hydrate selectors that need shared catalog dictionaries without duplicating the dictionaries module API.

Running catalog commands

import { createRequestContainer } from '@/lib/di/container'
import type { CommandBus } from '@saasframe/shared/lib/commands'

const container = await createRequestContainer()
const commandBus = container.resolve<CommandBus>('commandBus')

await commandBus.execute('catalog.products.create', {
input: {
organizationId,
tenantId,
name: 'Starter T-Shirt',
code: 'tee-basic',
primaryCurrencyCode: 'USD',
metadata: { category: 'apparel' },
customFields: { material: 'cotton' }
},
ctx: { auth, container }
})
  • Every command enforces tenant and organization scope via ensureTenantScope/ensureOrganizationScope (packages/core/src/modules/catalog/commands/shared.ts:42).
  • Custom fields can be supplied either as customFields object or prefixed keys (cf_color); handlers call parseWithCustomFields and setCustomFieldsIfAny to persist values (packages/core/src/modules/catalog/commands/products.ts:100).
  • Undo metadata is logged automatically; the audit log payload includes before/after snapshots for δ previews and rollbacks.

Product commands — catalog.products.*

Handlers live in packages/core/src/modules/catalog/commands/products.ts.

Command idPurposeSchema
catalog.products.createCreate a product master recordproductCreateSchema (packages/core/src/modules/catalog/data/validators.ts:18)
catalog.products.updatePatch an existing product (only provided keys are updated)productUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:32)
catalog.products.deleteSoft delete a product once variants/prices are cleaned upn/a (requires before.id in the undo snapshot)

Key fields from productCreateSchema:

  • organizationId, tenantId — required scope identifiers.
  • name (string, ≤255), optional description.
  • Optional catalog metadata: code (lowercase slug), statusEntryId, primaryCurrencyCode, defaultUnit, attributeSchemaId reference, attributeSchema JSON override, attributeValues map, and offers (array of { channelId, title, description }) that bind products to sales channels. When attributeSchemaId is provided, the product inherits a shared schema template; providing attributeSchema in the same payload overwrites individual fields for that single product without mutating the shared template.
  • defaultMediaId / defaultMediaUrl — optional attachment handle + its SEO-friendly relative image URL for the primary product media. Upload images via /api/attachments, then resolve responsive URLs with buildAttachmentImageUrl(id, { width, height, slug }) or by calling /api/attachments/image/{id}/{slug} directly for server-side resizing.
  • Flags: isConfigurable, isActive, plus arbitrary metadata JSON.

Example payload:

{
"organizationId": "3d552375-7813-4e9d-8807-0283c907a9f9",
"tenantId": "a2c6e50e-a6ef-4c7a-afe4-7b6f1998b5c2",
"name": "Starter T-Shirt",
"code": "tee-basic",
"primaryCurrencyCode": "USD",
"attributeSchemaId": "edc95a4d-9fb9-4cbc-b8ce-1b93af2c8e83",
"attributeSchema": {
"definitions": [
{ "key": "material", "label": "Material", "kind": "text" }
]
},
"offers": [
{ "channelId": "bf5efc35-c493-44e4-9307-5f50331a7405", "title": "Online store" }
],
"isConfigurable": true,
"metadata": { "category": "apparel" },
"customFields": { "material": "cotton" }
}

Variant commands — catalog.variants.*

Defined in packages/core/src/modules/catalog/commands/variants.ts.

Command idPurposeSchema
catalog.variants.createCreate a SKU tied to a productvariantCreateSchema (packages/core/src/modules/catalog/data/validators.ts:39)
catalog.variants.updatePatch variant attributes, dimensions, or option selectionsvariantUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:65)
catalog.variants.deleteRemove a variant after ensuring there are no prices or option linksn/a (id provided through undo payload)

Highlights from variantCreateSchema:

  • Required: organizationId, tenantId, productId.
  • Optional: name, sku, barcode, statusEntryId, isDefault, isActive.
  • Dimensions and weight via nested objects (dimensions.width/height/depth/unit, weightValue, weightUnit).
  • optionValues map keyed by option code to capture selections directly on the variant payload (also extracted from metadata.optionValues for backward compatibility).

Price commands — catalog.prices.*

Stored in packages/core/src/modules/catalog/commands/prices.ts.

Command idPurposeSchema
catalog.prices.createAdd a price row for a variantpriceCreateSchema (packages/core/src/modules/catalog/data/validators.ts:273)
catalog.prices.updateUpdate price attributespriceUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:298)
catalog.prices.deleteRemove a price entryn/a

Key fields:

  • variantId, organizationId, tenantId, and priceKindId.
  • currencyCode (ISO-4217) with optional price kind currency override.
  • Quantity bands: minQuantity, maxQuantity.
  • Price components: unitPriceNet, unitPriceGross, taxRate.
  • Effective dates: startsAt, endsAt (coerced to Date by Zod).

Shared tax calculation service

All price commands and the admin builders call the DI-provided taxCalculationService (registered by the Sales module) to keep net/gross/tax amounts consistent. The default implementation looks up the requested tax class (taxRateId) or explicit rate, normalizes non-negative inputs, and stores computed unitPriceNet, unitPriceGross, and taxAmount back on the catalog_product_prices row.

You can override/extend the calculation pipeline via the event bus:

  • sales.tax.calculate.before — receives { input, setInput(next), setResult(result) }. Call setInput() to tweak the pending payload (e.g., swap the taxRateId based on the customer/region) or setResult() to bypass the built-in calculator entirely.
  • sales.tax.calculate.after — receives { input, result, setResult(next) }. Use this to post-process the computed numbers (rounding, recoverable VAT adjustments, etc.).

Example hook (run from any module bootstrap/registrar):

import { createRequestContainer } from '@/lib/di/container'

const container = await createRequestContainer()
const eventBus = container.resolve('eventBus')

eventBus?.on('sales.tax.calculate.before', ({ input, setInput }) => {
if (!input.taxRateId && input.organizationId === 'org-brazil') {
setInput({ taxRateId: 'default-brazilian-rate' })
}
})

eventBus?.on('sales.tax.calculate.after', ({ result, setResult }) => {
if (result && result.taxAmount > 0) {
setResult({ ...result, taxAmount: Math.ceil(result.taxAmount * 100) / 100 })
}
})

Prefer taxRateId over raw percentages so the service can validate scope and reuse the Sales → Tax Rates admin UI.

Price kind commands — catalog.priceKinds.*

Stored in packages/core/src/modules/catalog/commands/priceKinds.ts.

Command idPurposeSchema
catalog.priceKinds.createRegister a reusable price kindpriceKindCreateSchema (packages/core/src/modules/catalog/data/validators.ts:258)
catalog.priceKinds.updateAdjust labels, display mode, currency, or promotion flagspriceKindUpdateSchema (packages/core/src/modules/catalog/data/validators.ts:271)
catalog.priceKinds.deleteSoft delete a price kind after ensuring no prices reference itn/a

Custom field fieldsets

Catalog products and variants no longer ship their own “attribute schema” concept. Instead they rely on the shared custom-fields engine and the CustomFieldEntityConfig fieldset metadata that is edited from Entities → System entities → catalog.catalog_product (and catalog.catalog_product_variant). Compose as many fieldsets as you need—e.g. fashion, equipment, services—and assign custom fields plus per-field groups within each set. The generated UI automatically scopes the editors, CRUD forms, and filters to the currently selected fieldset.

Key details:

  • Fieldset metadata lives in custom_field_entity_configs and is exposed through /api/entities/definitions?entityId=…. The request accepts ?fieldset=<code> to filter definitions server-side; the admin UI calls this automatically when the user switches fieldsets.
  • Products (catalog_products) and variants (catalog_product_variants) persist the chosen fieldset in the custom_fieldset_code column. Awilix commands and CRUD handlers already accept a customFieldsetCode property so you can capture that selection from bespoke flows.
  • Listing APIs allow a customFieldset=<code> query parameter so filters and the query engine interpret cf_* filters using the right definition set. The backend uses buildCustomFieldFiltersFromQuery(..., { fieldset }) to scope validation and coercion.
  • Because we now reuse custom fields, all of the generic helpers (collectCustomFieldValues, crud.create/update payloads, entity definitions UI) apply without a bespoke schema layer. Old attribute schema endpoints/tests have been removed; the existing migrations (up to Migration20251116183727) take care of dropping the template tables and extra JSON columns.

Customising price selection

All API surfaces that need “best price” calculations resolve the DI token catalogPricingService, which wraps resolveCatalogPrice() from packages/core/src/modules/catalog/lib/pricing.ts. The helper emits catalog.pricing.resolve.before and catalog.pricing.resolve.after events and walks a prioritized hook registry, falling back to the built-in layered selection (selectBestPrice) when no hook handles the request.

You can plug in custom logic (e.g., loyalty tiers, partner overrides) by registering a resolver from any module initialization file:

import { registerCatalogPricingResolver } from '@saasframe/core/modules/catalog/lib/pricing'

registerCatalogPricingResolver(
async (rows, ctx) => {
// rows: all matching CatalogProductPrice rows (with populated offer/variant/product)
// ctx: PricingContext (channelId, userId, quantity, date, etc.)
const partnerPrice = rows.find((row) => row.metadata?.partner === ctx.customerId)
if (partnerPrice) return partnerPrice
return undefined // fall back to the next resolver / default algorithm
},
{ priority: 100 },
)
  • Resolvers run in descending priority order (default is 0); return undefined to defer to the next handler.
  • Returning null stops the pipeline and instructs callers that no price applies.
  • The default resolver (selectBestPrice) still runs last so existing behaviour remains unless you override it.
  • The DI service automatically injects the app’s event bus; listeners can override prices by handling catalog.pricing.resolve.before|after (each payload exposes rows, context, result, and setResult, mirroring the sales calculation hooks).
  • Swap the pricing implementation entirely by registering your own catalogPricingService via DI if you need to replace the pipeline wholesale.

Feature flags

RBAC gates for REST/UX surfaces are declared in packages/core/src/modules/catalog/acl.ts:1:

  • catalog.products.view / catalog.products.manage
  • catalog.variants.manage
  • catalog.pricing.manage
  • catalog.settings.manage

Grant these to API keys or roles you intend to use with wrapper endpoints so future upgrades continue to respect access boundaries.


You can call the command bus directly or go through the REST endpoints above—the schemas listed here ensure imports, integrations, and tests remain aligned with the core module.