Supercommerce API Docs
Vendor API

Catalog Module — Vendor surface

Vendor-facing CRUD over the vendor's own products, variants, options, and tabs, plus the vendor-side "request a new taxonomy entry" workflow (new brand, category, tag, or…

Vendor-facing CRUD over the vendor's own products, variants, options, and tabs, plus the vendor-side "request a new taxonomy entry" workflow (new brand, category, tag, or ingredient). A product belongs to one vendor and carries a soft-delete flag; variants carry the canonical HSN code that the order module snapshots at place-time.

Source: api-modules/catalog/src/controllers/vendor-product.controller.ts, api-modules/catalog/src/controllers/vendor-product-variant.controller.ts, api-modules/catalog/src/controllers/vendor-product-tab.controller.ts, api-modules/catalog/src/controllers/vendor-catalog-request.controller.ts.


Conventions

Authentication

All endpoints require a Better-Auth bearer session with an active vendor.

Authorization: Bearer <session-token>

The vendor is resolved via resolveActiveVendorId(session) — prefers session.session.activeOrganizationId (better-auth canonical) and falls back to activeVendorId.

Tenant scoping

Every read and write scopes to the active vendor's id. Product ids, variant ids, or catalog-request ids that belong to a different vendor return 404 Not Found, never 403 — so existence of another vendor's row can't be inferred.

Response envelope

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
404NOT_FOUND
409CONFLICT, UNIQUE_VIOLATION (slug taken)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Money + dates

All monetary fields (price, specialPrice) are integer subunits (paise / cents / eurocents). Date fields (publishedAt, specialPriceStart, specialPriceEnd) are ISO datetimes.

HSN code

HSN classification lives on product_variant (hsnCode), not on product. The product also carries informational hsCode/midCode but the canonical value snapshotted onto order_line.hsn_code_at_order is the variant's hsnCode. Empty strings are rejected — send null explicitly to clear.


Domain types

ProductStatus, ProductVisibility

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

ProductDetail

Returned by :id/detail and writes that touch the whole product.

type ProductDetail = {
  id: string;
  vendorId: string;
  title: string;
  slug: string;                              // /^[a-z0-9]+(?:-[a-z0-9]+)*$/
  subtitle: string | null;
  description: string | null;
  brandId: string | null;
  primaryCategoryId: string | null;
  material: string | null;
  countryOfOrigin: string | null;
  hsCode: string | null;                     // informational only
  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[];
  variants: ProductVariant[];
  tabs: ProductTab[];
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
};

ProductVariant

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

ProductOption, ProductTab

type ProductOption = {
  id: string;
  productId: string;
  name: string;                              // e.g. "Color"
  sortOrder: number;
  values: Array<{ id: string; value: string; sortOrder: number }>;
};

type ProductTab = {
  id: string;
  productId: string;
  title: string;
  body: string | null;
  isActive: boolean;
  sortOrder: number;
};

CatalogRequest

type CatalogRequestStatus = "pending" | "approved" | "rejected";

type CatalogRequest = {
  id: string;
  title: string;
  description: string | null;
  slug: string;
  image: string | null;
  metadata: Record<string, unknown> | null;
  status: CatalogRequestStatus;
  vendorId: string;
  requestedByUserId: string;
  rejectionReason: string | null;
  approvedAt: string | null;
  rejectedAt: string | null;
  resultingItemId: string | null;            // canonical id populated on approval
  createdAt: string;
  updatedAt: string;
};

// Categories add parent linkage.
type CategoryRequest = CatalogRequest & { parentId: string | null };

Products

Base path: /vendor/products.

POST /vendor/products — Create a product

Single-call create that can include the entire option matrix, variants, and tabs.

BodyCreateProductInput

{
  "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",                         // default "draft"
  "visibility": "public",                    // default "public"
  "publishedAt": null,
  "categoryIds": ["01J9..."],
  "tagIds": ["01J9..."],
  "ingredientIds": ["01J9..."],
  "options": [
    { "name": "Color", "sortOrder": 0, "values": [
      { "value": "Red", "sortOrder": 0 },
      { "value": "Blue", "sortOrder": 1 }
    ] }
  ],
  "variants": [
    {
      "sku": "TSHIRT-RED-M",
      "price": 99900,
      "specialPrice": 89900,
      "hsnCode": "61091000",                 // canonical
      "minQuantityPerCart": 1,
      "maxQuantityPerCart": 10,
      "optionValues": [
        { "optionName": "Color", "value": "Red" },
        { "optionName": "Size", "value": "M" }
      ]
    }
  ],
  "tabs": [
    { "title": "Care", "body": "Machine wash cold", "sortOrder": 0 }
  ]
}

Notable constraints:

  • slug matches /^[a-z0-9]+(?:-[a-z0-9]+)*$/ and is unique among non-deleted products.
  • title ≤ 255 chars.
  • All foreign-key ids (brandId, categoryIds, tagIds, ingredientIds) must reference non-deleted rows in the canonical tables.
  • Variant cross-field rules: specialPrice < price when both set, specialPriceEnd > specialPriceStart when both set, maxQuantityPerCart >= minQuantityPerCart when both set.
  • Nested variant optionValues[] uses (optionName, value) tuples — the server resolves them to option-value ids inside the parent transaction.

Response 201ProductDetail.

Side effects — emits catalog.product.created (consumed by the search reindexer + cart price-cache invalidator).

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod (slug regex, cross-field rules, etc.)
409UNIQUE_VIOLATIONSlug already taken

GET /vendor/products — List my products

Standard QueryDto query — page / limit / search / sort. Returns paginated products (summary shape — no nested option matrix).

Response 200 — paginated envelope.


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

Returns the product row + first-class fields. No nested options, variants, or 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[].

Errors

StatusCodeWhen
404NOT_FOUNDProduct not owned by active vendor

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

Partial update. Body fields are the same first-class fields as POST minus thumbnail, images, options, variants, tabs (those have their own endpoints).

BodyUpdateProductBasicsInput (every field optional)

{
  "title": "Red Tee v2",
  "slug": "red-tee-v2",
  "status": "active",
  "visibility": "public",
  "publishedAt": "2026-05-01T00:00:00.000Z",
  "categoryIds": ["01J9..."],
  "tagIds": ["01J9..."],
  "ingredientIds": []
}

Response 200ProductDetail. Emits catalog.product.updated.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
404NOT_FOUNDProduct not owned by active vendor
409UNIQUE_VIOLATIONSlug change collides with another product

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

Body

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

Response 200ProductDetail. Emits catalog.product.updated.


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

Whole-set replace of options[] (and each option's values[]). Existing variants whose optionValueIds no longer resolve are detached (sort order preserved but option-value links emptied). Most edit UIs use /sync instead.

Body

{
  "options": [
    { "name": "Size", "sortOrder": 0, "values": [
      { "value": "S", "sortOrder": 0 },
      { "value": "M", "sortOrder": 1 },
      { "value": "L", "sortOrder": 2 }
    ] }
  ]
}

Response 200ProductDetail. Emits catalog.product.updated.


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

Replace basics + media + options + variants + tabs in one transaction. The partial-failure surface is small — a write either fully succeeds or fully rolls back. Most edit screens call this on save.

Body — combines UpdateProductBasicsInput, UpdateProductMediaInput, options array, variants array, tabs array. Variants and tabs may carry id to update in-place, or omit it to insert.

{
  "basics": { /* UpdateProductBasicsInput */ },
  "media":  { "thumbnail": "...", "images": ["..."] },
  "options": [ /* ProductOption[] (replace-all) */ ],
  "variants": [
    {
      "id": "01J9...",                       // omit to create
      "sku": "TSHIRT-RED-M",
      "price": 99900,
      "hsnCode": "61091000",
      "optionValues": [
        { "optionName": "Color", "value": "Red" },
        { "optionName": "Size", "value": "M" }
      ]
    }
  ],
  "tabs": [
    { "id": "01J9...", "title": "Care", "body": "...", "isActive": true, "sortOrder": 0 }
  ]
}

Response 200ProductDetail. Emits catalog.product.updated.


DELETE /vendor/products/:id — Soft delete

Sets deletedAt. Reversible via support tooling — there is no public restore endpoint on the vendor surface.

Response 200 — emits catalog.product.deleted.

Errors

StatusCodeWhen
404NOT_FOUNDProduct not owned by active 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 exist for incremental UIs.

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

GET /vendor/products/:productId/variants — List variants

Returns all non-deleted variants for the product, ordered by sortOrder.

Errors

StatusCodeWhen
404NOT_FOUNDProduct not owned by active vendor

POST /vendor/products/:productId/variants — Create a variant

BodyCreateProductVariantInput

{
  "thumbnail": "https://...",
  "images": ["https://..."],
  "price": 99900,
  "specialPrice": 89900,
  "specialPriceStart": "2026-05-01T00:00:00.000Z",
  "specialPriceEnd":   "2026-05-15T00:00:00.000Z",
  "sku": "TSHIRT-RED-M",
  "ean": null,
  "upc": null,
  "barcode": null,
  "hsnCode": "61091000",
  "minQuantityPerCart": 1,
  "maxQuantityPerCart": 10,
  "sortOrder": 0,
  "optionValueIds": ["<color-red>", "<size-m>"]
}

Cross-field rules:

  • specialPrice < price when both set.
  • specialPriceEnd > specialPriceStart when both set.
  • maxQuantityPerCart >= minQuantityPerCart when both set.
  • hsnCode must be non-empty after trimming, ≤ 32 chars; send null to clear.

Response 201 — created ProductVariant. Emits catalog.variant.created.


PUT /vendor/products/:productId/variants/reorder — Reorder variants

Body

{
  "variants": [
    { "variantId": "01J9...", "sortOrder": 0 },
    { "variantId": "01J9...", "sortOrder": 1 }
  ]
}

variants[] must be at least one entry; entries should cover every non-deleted variant for stable ordering.

Response 200 — list of variants in new order. Emits catalog.variant.updated.


PATCH /vendor/products/:productId/variants/:variantId — Update a variant

Field-level partial update. The same cross-field rules apply to the post-merge value.

Response 200 — updated ProductVariant. Emits catalog.variant.updated.

Errors

StatusCodeWhen
404NOT_FOUNDProduct or variant not owned by active vendor

DELETE /vendor/products/:productId/variants/:variantId — Soft delete a variant

Sets deletedAt. Emits catalog.variant.deleted.


Product tabs

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

GET /vendor/products/:productId/tabs — List tabs

Returns active + inactive tabs for the product, ordered by sortOrder.

POST /vendor/products/:productId/tabs — Create a tab

Body

{
  "title": "Care",
  "body": "Machine wash cold",               // nullable
  "isActive": true,                          // default true
  "sortOrder": 0                             // optional
}

Response 201 — created ProductTab.

PUT /vendor/products/:productId/tabs/reorder — Reorder tabs

Body

{
  "tabs": [
    { "tabId": "01J9...", "sortOrder": 0 },
    { "tabId": "01J9...", "sortOrder": 1 }
  ]
}

PATCH /vendor/products/:productId/tabs/:tabId — Update a tab

Partial update — title, body, isActive, sortOrder.

DELETE /vendor/products/:productId/tabs/:tabId — Soft delete a tab


Catalog requests

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

Four parallel sub-resources — categories, brands, tags, ingredients. Body shape is the standard CreateCatalogItemInput minus isActive (admin sets that flag on approval); the categories variant additionally accepts a nullable parentId.

POST /vendor/catalog/requests/:taxonomy — Submit a request

:taxonomycategories | brands | tags | ingredients.

BodyCreateCatalogRequestInput

{
  "title": "Sustainable Cotton",
  "description": "...",
  "slug": "sustainable-cotton",              // regex /^[a-z0-9]+(?:-[a-z0-9]+)*$/
  "image": "https://...",                    // nullable
  "metadata": { "any": "json" }              // nullable
}

For categories, additionally:

{ "parentId": "01J9..." }                    // nullable

Response 201CatalogRequest with status="pending". Emits catalog.request.submitted.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod (slug regex, missing title)

PUT /vendor/catalog/requests/:taxonomy/:id — Update a pending request

Allowed only while the request is still pending. Already-approved or rejected requests return 409.

Response 200 — updated CatalogRequest.

Errors

StatusCodeWhen
404NOT_FOUNDRequest not owned by active vendor
409CONFLICTRequest is not pending

GET /vendor/catalog/requests/:taxonomy — List my requests

Standard QueryDto. Scoped to the active vendor.

Response 200 — paginated envelope of CatalogRequest.


GET /vendor/catalog/requests/:taxonomy/:id — Get a request by id

Returns the request. Catalog requests are intentionally accessible by any authenticated user (admins regularly link to them); the row itself carries vendorId so a vendor can verify ownership client-side.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

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.request.submitted / .approved / .rejectedVendor request lifecycle

  • inventory — every variant has an inventory row. Vendor stock policy + manual adjustments live in inventory.md.
  • product-attribute — vendor + admin attribute groups attached to products. See product-attribute.md.
  • search — storefront product browsing / filtering / facets. Catalog writes emit events the reindexer consumes. See search.md.
  • cart + order — cart pricing reads from product_variant; order line snapshots hsn_code_at_order from the variant.

On this page

ConventionsAuthenticationTenant scopingResponse envelopeError envelopeMoney + datesHSN codeDomain typesProductStatus, ProductVisibilityProductDetailProductVariantProductOption, ProductTabCatalogRequestProductsPOST /vendor/products — Create a productGET /vendor/products — List my productsGET /vendor/products/:id — Get product summaryGET /vendor/products/:id/detail — Full product for editPATCH /vendor/products/:id/basics — Update descriptive + SEO + status + taxonomyPATCH /vendor/products/:id/media — Update thumbnail + imagesPUT /vendor/products/:id/options — Replace product optionsPUT /vendor/products/:id/sync — Atomic full-product syncDELETE /vendor/products/:id — Soft deleteProduct variantsGET /vendor/products/:productId/variants — List variantsPOST /vendor/products/:productId/variants — Create a variantPUT /vendor/products/:productId/variants/reorder — Reorder variantsPATCH /vendor/products/:productId/variants/:variantId — Update a variantDELETE /vendor/products/:productId/variants/:variantId — Soft delete a variantProduct tabsGET /vendor/products/:productId/tabs — List tabsPOST /vendor/products/:productId/tabs — Create a tabPUT /vendor/products/:productId/tabs/reorder — Reorder tabsPATCH /vendor/products/:productId/tabs/:tabId — Update a tabDELETE /vendor/products/:productId/tabs/:tabId — Soft delete a tabCatalog requestsPOST /vendor/catalog/requests/:taxonomy — Submit a requestPUT /vendor/catalog/requests/:taxonomy/:id — Update a pending requestGET /vendor/catalog/requests/:taxonomy — List my requestsGET /vendor/catalog/requests/:taxonomy/:id — Get a request by idDomain eventsRelated modules