Supercommerce API Docs
Full Module Docs

Catalog Module

HTTP surface for the product catalog — vendor-facing CRUD over products, variants, options, tabs; vendor-side "request a new taxonomy entry" workflow; admin CRUD + approval over…

HTTP surface for the product catalog — vendor-facing CRUD over products, variants, options, tabs; vendor-side "request a new taxonomy entry" workflow; admin CRUD + approval over the platform-wide taxonomy (brands, categories, tags, ingredients); admin read of products and a flat cross-product variant picker; and unauthenticated public reads of active taxonomy for the storefront.

Source: api-modules/catalog (registered via CatalogModule.forRoot() in apps/api/src/app.module.ts).

Cart / order / inventory consume product+variant data through the catalog module; the cart pricing snapshot reads product_variant.price / special_price and the order line snapshots hsn_code from the variant. HSN classification lives on product_variant, not product — variants in the same family can have different HSN codes (cosmetics vs. electronics packs).


Conventions

Authentication

Endpoint groupAuthPermission
GET /store/catalog/**none
/vendor/products/**, /vendor/products/:productId/variants/**, /vendor/products/:productId/tabs/**, /vendor/catalog/requests/**required (vendor session)active vendor in session
GET /admin/products, GET /admin/products/:id/detail, GET /admin/variantsrequiredproduct: view
GET /admin/catalog/brands/**requiredbrand: read
POST /admin/catalog/brandsrequiredbrand: create
PUT /admin/catalog/brands/:id, POST /admin/catalog/brands/:id/restorerequiredbrand: update
DELETE /admin/catalog/brands/:idrequiredbrand: delete
POST /admin/catalog/brands/requests/:id/approve|rejectrequiredbrand: approve
/admin/catalog/categories/**same as brands but on category:*
/admin/catalog/tags/**same on tag:*
/admin/catalog/ingredients/**same on ingredient:*

Vendor endpoints resolve the active vendor via resolveActiveVendorId(session). Vendor-owned ids that belong to a different vendor return 404 Not Found (no row leak).

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Some admin endpoints return a picker envelope instead of a plain page — see Picker responses below.

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, UNIQUE_VIOLATION (slug or SKU collision), FOREIGN_KEY_VIOLATION
422UNPROCESSABLE_ENTITY
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Lifecycle / soft-delete

Brands, categories, tags, ingredients, products, variants, and product tabs all soft-delete via a deletedAt timestamp. A DELETE flips deletedAt; POST /restore clears it. Soft-deleted rows are hidden from default listings and public reads but visible in admin list filters.

Slugs are unique among non-deleted rows. Deleting a brand/category/tag/ingredient frees its slug for re-use; restoring a row may fail with UNIQUE_VIOLATION if the slug has been taken in the meantime.

Currency

Variant price fields (price, specialPrice) are integer subunits (paise / cents / eurocents — single currency per tenant). Send and receive integers; never decimal currency strings.

Picker responses

GET /admin/catalog/brands, GET /admin/catalog/categories, GET /admin/catalog/tags, GET /admin/catalog/ingredients, and GET /admin/variants return a picker envelope that pairs the regular paginated items[] with a pinned[] list of always-shown rows. Use the selectedIds=<csv> query param to pre-pin currently-selected items so the picker never has to manually search to render an already-applied selection:

{
  "data": {
    "items": [ /* page of unpinned rows */ ],
    "pinned": [ /* full rows for every id in selectedIds */ ]
  },
  "metadata": { /* page, limit, total — for items[] only */ }
}

When selectedIds is absent, pinned is [].

Domain events

Emitted via EventEmitter2 after writes — consumed by the search reindexer and the cart price-cache invalidator.

EventFired when
catalog.product.createdPOST /vendor/products
catalog.product.updatedany product write (basics, media, options, sync)
catalog.product.deletedDELETE /vendor/products/:id
catalog.variant.created / .updated / .deletedvariant writes
catalog.brand.*, .category.*, .tag.*, .ingredient.*admin taxonomy writes
catalog.request.submitted / .approved / .rejectedvendor request lifecycle

Domain types

Taxonomy item — CatalogItemResponse

Shared by brand / category / tag / ingredient. Used by both admin and public reads.

type CatalogItemResponse = {
  id: string;
  title: string;
  description: string | null;
  slug: string;                            // lowercase alnum + hyphens
  image: string | null;
  metadata: Record<string, unknown> | null;
  isActive: boolean;
  createdAt: string;                       // ISO
  updatedAt: string;
  deletedAt: string | null;
};

// Admin list-only variant adds an aggregated banner count from the
// polymorphic `banner` table (so the UI can warn before deleting a
// brand/category that still has banners attached).
type AdminCatalogItemResponse = CatalogItemResponse & {
  bannerCount: number;
};

Product

A product belongs to one vendor and has zero-to-many variants, options, tabs, categories, tags, and ingredients. India GST classification (hsCode/midCode) on the product is informational; the canonical HSN for orders/invoices is on the variant (hsnCode).

type ProductStatus = "draft" | "active" | "archived";
type ProductVisibility = "public" | "private";

type ProductDetail = {
  id: string;
  vendorId: string;
  title: string;
  slug: string;
  subtitle: string | null;
  description: string | null;
  brandId: string | null;
  primaryCategoryId: string | null;
  material: string | null;
  countryOfOrigin: string | null;
  hsCode: string | null;                    // informational
  midCode: string | null;
  thumbnail: string | null;
  images: string[];
  metaTitle: string | null;
  metaDescription: string | null;
  ogImage: string | null;
  status: ProductStatus;
  visibility: ProductVisibility;
  publishedAt: string | null;               // ISO
  categories: CatalogItemResponse[];
  tags: CatalogItemResponse[];
  ingredients: CatalogItemResponse[];
  options: ProductOption[];                 // see "Options + variants" below
  variants: ProductVariant[];
  tabs: ProductTab[];
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
};

Product variant

type ProductVariant = {
  id: string;
  productId: string;
  thumbnail: string | null;
  images: string[];
  price: number | null;                     // subunits
  specialPrice: number | null;              // subunits; must be < price when both set
  specialPriceStart: string | null;         // ISO
  specialPriceEnd: string | null;
  sku: string | null;
  ean: string | null;
  upc: string | null;
  barcode: string | null;
  hsnCode: string | null;                   // India GST classificationcanonical for orders
  minQuantityPerCart: number | null;
  maxQuantityPerCart: number | null;        // must be >= minQuantityPerCart when both set
  sortOrder: number;
  optionValueIds: string[];                 // links the variant to one value per option
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
};

Product option + tabs

Options + their values define the option matrix (Color, Size, etc.). A variant references one optionValueId per option.

type ProductOption = {
  id: string;
  productId: string;
  name: string;            // e.g. "Color"
  position: number;
  values: ProductOptionValue[];
};

type ProductOptionValue = {
  id: string;
  optionId: string;
  label: string;           // e.g. "Red"
  position: number;
};

// "Tabs" are free-form rich-text blocks ("Description", "Care",
// "Returns" etc.) rendered as accordions on the product page.
type ProductTab = {
  id: string;
  productId: string;
  title: string;
  body: string;            // markdown / sanitized HTML
  position: number;
};

Catalog requests

Vendor-submitted taxonomy proposals (new brand, new category, etc.). Admin reviews and approves into the canonical table or rejects with a reason.

type CatalogRequest = CatalogItemResponse & {
  status: "pending" | "approved" | "rejected";
  vendorId: string;
  requestedByUserId: string;
  rejectionReason: string | null;
  approvedAt: string | null;
  rejectedAt: string | null;
  resultingItemId: string | null;          // populated on approval
};

// Category-only adds parent linkage.
type CategoryRequest = CatalogRequest & {
  parentId: string | null;
};

Storefront — GET /store/catalog/**

Unauthenticated reads of active (non-archived, non-deleted) taxonomy items. Used by storefront category pages, brand pages, etc.

Method + pathReturns
GET /store/catalog/categoriespaginated list of CatalogItemResponse (active only)
GET /store/catalog/categories/treenested tree, parent → children
GET /store/catalog/categories/slug/:slugactive category by slug
GET /store/catalog/categories/:idactive category by id
GET /store/catalog/brandspaginated list of CatalogItemResponse (active only)
GET /store/catalog/brands/slug/:slugactive brand by slug
GET /store/catalog/brands/:idactive brand by id
GET /store/catalog/tagspaginated list
GET /store/catalog/tags/slug/:slugactive tag by slug
GET /store/catalog/tags/:idactive tag by id
GET /store/catalog/ingredientspaginated list
GET /store/catalog/ingredients/slug/:slugactive ingredient by slug
GET /store/catalog/ingredients/:idactive ingredient by id

Soft-deleted or isActive=false rows return 404 Not Found.

Product browsing on the storefront uses the search module (docs/search.md) — not catalog — because it needs filters, facets, and full-text search.


Vendor — Products

Base path: /vendor/products. Scope: active vendor on the session.

POST /vendor/products — Create a product

Body (all fields validated by createProductSchema)

{
  "title": "Red Tee",
  "slug": "red-tee",                       // optional — auto-derived from title if omitted
  "subtitle": "...",
  "description": "...",
  "brandId": "01J9...",
  "primaryCategoryId": "01J9...",
  "material": "100% cotton",
  "countryOfOrigin": "IN",
  "hsCode": "61091000",                    // informational
  "midCode": null,
  "thumbnail": "https://...",
  "images": ["https://...", "..."],
  "metaTitle": "...",
  "metaDescription": "...",
  "ogImage": "https://...",
  "status": "draft",                       // "draft" | "active" | "archived"
  "visibility": "public",                  // "public" | "private"
  "publishedAt": null,
  "categoryIds": ["01J9..."],
  "tagIds": ["01J9..."],
  "ingredientIds": ["01J9..."],
  "options": [
    { "name": "Color", "position": 0, "values": [
      { "label": "Red", "position": 0 },
      { "label": "Blue", "position": 1 }
    ] }
  ],
  "variants": [
    {
      "sku": "TSHIRT-RED-M",
      "price": 99900,
      "specialPrice": 89900,
      "hsnCode": "61091000",               // canonical for orders
      "minQuantityPerCart": 1,
      "maxQuantityPerCart": 10,
      "optionValueIds": ["<Red>", "<M>"]
    }
  ],
  "tabs": [
    { "title": "Care", "body": "Machine wash cold", "position": 0 }
  ]
}

Notable constraints:

  • slug must match /^[a-z0-9]+(?:-[a-z0-9]+)*$/. Unique among non-deleted products.
  • On a variant: specialPrice must be < price when both are set; specialPriceEnd must be after specialPriceStart; maxQuantityPerCart >= minQuantityPerCart.
  • All foreign-key ids (brandId, categoryIds, tagIds, ingredientIds, etc.) must reference non-deleted rows in the canonical tables.

Response 201ProductDetail.


GET /vendor/products — List my products

Standard QueryDto query — page / limit / search / sort. Returns paginated ProductDetail (without nested option matrix; use :id/detail for the full edit shape).


GET /vendor/products/:id — Get product (summary)

Returns the product row + first-class fields. No nested options/variants/tabs — use :id/detail for those.

Errors

StatusCodeWhen
404NOT_FOUNDProduct not owned by active vendor

GET /vendor/products/:id/detail — Full product for edit

Returns ProductDetail including options, variants, tabs, and resolved categories[] / tags[] / ingredients[].


PATCH /vendor/products/:id/basics — Update descriptive + SEO + status + taxonomy

Partial update. Body fields are exactly the create-time fields except thumbnail, images, options, variants, tabs (those have their own endpoints).

Response 200ProductDetail.


PATCH /vendor/products/:id/media — Update thumbnail + images

{
  "thumbnail": "https://...",
  "images": ["https://...", "..."]
}

PUT /vendor/products/:id/options — Replace options

Whole-set replace of options[] (and their values[]). Existing variants whose optionValueIds no longer resolve are detached (sort order preserved but option-value links emptied). Vendors typically use /sync instead of mutating options in isolation.


PUT /vendor/products/:id/sync — Atomic full-product sync

Replace basics + media + options + variants + tabs in one transaction. Use this from the edit screen so the partial-failure surface is small (a write either fully succeeds or fully rolls back).

Body combines the bodies of the five endpoints above. See dto/product/sync-product.dto.ts for the exact schema.


DELETE /vendor/products/:id — Soft delete

Sets deletedAt. Reversible via support tooling (no public restore endpoint on the vendor surface).


Vendor — Product variants

Base path: /vendor/products/:productId/variants. The variant matrix can also be edited via PUT /vendor/products/:id/sync; these per-variant endpoints are for incremental UIs.

Method + pathBodyNotes
GET /List variants for product (active vendor only)
POST /CreateProductVariantInputCreate. specialPrice < price, specialPriceEnd > specialPriceStart, max >= min cross-field rules apply
PUT /reorder{ ids: string[] }Replace sort order. ids[] must exactly match the current variant set
PATCH /:variantIdpartial CreateProductVariantInputField-level update; the same cross-field rules apply to the post-merge value
DELETE /:variantIdSoft delete

A variant's hsnCode is what's snapshotted onto the order_line.hsn_code_at_order at place-time (see order.md).


Vendor — Product tabs

Base path: /vendor/products/:productId/tabs. Free-form rich-text blocks rendered as accordions on the storefront product page.

Method + pathBodyNotes
GET /List tabs (active vendor only), ordered by position
POST /CreateProductTabInputCreate
PUT /reorder{ ids: string[] }Replace sort order
PATCH /:tabIdpartialUpdate one tab
DELETE /:tabIdSoft delete

Vendor — Catalog requests

Base path: /vendor/catalog/requests. Vendors propose new taxonomy entries (a brand we don't carry yet, a missing category, etc.) and admin reviews. Approved requests get the canonical id written back as resultingItemId.

Pattern per taxonomy (categories, brands, tags, ingredients):

Method + pathBodyNotes
POST /:taxonomyCreateCatalogRequestInput (categories adds parentId)Submit a new request; starts pending
PUT /:taxonomy/:idsameUpdate a still-pending request. rejected/approved requests reject with 409
GET /:taxonomyList my own requests for this taxonomy
GET /:taxonomy/:idGet a request by id (any vendor — non-leak: it's vendor-visible because admins routinely link them)

CreateCatalogRequestInput is the regular CreateCatalogItemInput minus isActive (admin sets the active flag on approval).


Admin — Products

Method + pathPermissionNotes
GET /admin/productsproduct: viewList across all vendors. Accepts AdminProductQueryDto — page/limit/search + vendor/status/visibility filters
GET /admin/products/:id/detailproduct: viewFull ProductDetail plus vendor info
GET /admin/variantsproduct: viewCross-product variant picker; returns the picker envelope of AdminVariantListItem (variant + parent product summary). Searches across product title and variant SKU

Admin variant picker is used by features that target individual variants (e.g. free-gift rules, discount include/exclude filters).


Admin — Brands / Categories / Tags / Ingredients

Four parallel resources under /admin/catalog/{brands|categories|tags|ingredients}. Identical shape; only the permission resource differs (brand, category, tag, ingredient).

Item CRUD

Method + pathPermissionNotes
GET /<resource>: readPicker envelope of AdminCatalogItemResponse (with bannerCount). selectedIds=<csv> pre-pins rows
GET /tree (category only)category: readHierarchical tree (active + inactive, admin scope)
GET /:id<resource>: readReturns CatalogItemResponse. 404 on unknown id
POST /<resource>: createBody: CreateCatalogItemInput. Slug unique among non-deleted
PUT /:id<resource>: updateBody: UpdateCatalogItemInput (partial)
DELETE /:id<resource>: deleteSoft delete
POST /:id/restore<resource>: updateClears deletedAt. May 409 if slug conflicts post-restore

CreateCatalogItemInput

{
  "title": "Red",
  "description": "...",
  "slug": "red",
  "image": "https://...",
  "metadata": { "any": "json" },
  "isActive": true
}
FieldTypeConstraints
titlestring1..255 chars
descriptionstring | null?≤2000 chars
slugstring1..255 chars, regex ^[a-z0-9]+(?:-[a-z0-9]+)*$
imagestring | null?URL
metadataRecord<string, unknown> | null?Free-form JSON
isActiveboolean?Default true

Requests review

Sub-resource under each taxonomy at requests/. Admin reviews vendor-submitted proposals.

Method + pathPermissionNotes
GET /requests/all<resource>: readPaginated list of pending+rejected+approved requests
GET /requests/:id<resource>: readRequest detail
POST /requests/:id/approve<resource>: approveCreates the canonical item from the request payload; stamps approvedAt + resultingItemId
POST /requests/:id/reject<resource>: approveBody: { reason: string } (1..2000 chars). Stamps rejectedAt

Approving a request is idempotent in name — a second approve on an already-approved request returns 409. The same applies to reject.


Configuration

No env vars specific to the catalog module. Behavior is entirely controlled via vendor settings (which are managed by the settings module) and the platform RBAC permissions.


  • product-attribute — vendor + admin attribute groups attached to products. See product-attribute.md.
  • search — storefront product browsing / filtering / facet listing. Catalog writes emit events the search reindexer consumes. See search.md.
  • cart + order — cart pricing reads from product_variant; order line snapshots hsn_code_at_order from the variant. See cart.md, order.md.
  • banner — admin taxonomy lists include bannerCount so deleting a brand/category warns about attached banners.

On this page