Supercommerce API Docs
Admin API

Catalog Module — Admin

HTTP surface for platform-admin reads of products + variants and full CRUD over the platform-wide taxonomy (brands, categories, tags, ingredients), with a vendor-request approval…

HTTP surface for platform-admin reads of products + variants and full CRUD over the platform-wide taxonomy (brands, categories, tags, ingredients), with a vendor-request approval workflow per taxonomy. Vendor-side product/variant/tab CRUD and the public storefront reads live in the same module but are out of scope here — see vendor/catalog.md and store/catalog.md.

Source: api-modules/catalog/src/controllers/admin-product.controller.ts, api-modules/catalog/src/controllers/admin-variant.controller.ts, api-modules/catalog/src/controllers/admin-brand.controller.ts, api-modules/catalog/src/controllers/admin-category.controller.ts, api-modules/catalog/src/controllers/admin-tag.controller.ts, api-modules/catalog/src/controllers/admin-ingredient.controller.ts.

HSN classification lives on product_variant, not product — variants in the same family can have different HSN codes. The admin product detail endpoint surfaces both the informational product-level hsCode and the canonical per-variant hsnCode.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching permission.

Endpoint groupPermission
GET /admin/products, GET /admin/products/:id/detail, GET /admin/variantsproduct: view
GET /admin/catalog/brands/**brand: read
POST /admin/catalog/brandsbrand: create
PUT /admin/catalog/brands/:id, POST /admin/catalog/brands/:id/restorebrand: update
DELETE /admin/catalog/brands/:idbrand: delete
POST /admin/catalog/brands/requests/:id/approve|rejectbrand: approve
/admin/catalog/categories/**category:* (same shape as brands)
/admin/catalog/tags/**tag:* (same shape)
/admin/catalog/ingredients/**ingredient:* (same shape)

Response envelope

Successful responses are wrapped by ResponseInterceptor:

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

Some admin list endpoints return a picker envelope (items[] + pinned[]) — see Picker responses.

Error envelope

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

Lifecycle / soft-delete

Brands, categories, tags, ingredients, products, and variants all soft-delete via deletedAt. DELETE flips deletedAt; POST /:id/restore clears it. Slugs are unique among non-deleted rows — soft-deleting frees the slug for re-use; restoring may then fail with UNIQUE_VIOLATION if the slug has been taken in the meantime.

Currency

Variant price / specialPrice fields are integer subunits (paise / cents). Single currency per tenant.


Domain types

Taxonomy item — CatalogItemResponse

Shared by brand / category / tag / ingredient.

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

AdminCatalogItemResponse

Admin list-only variant adds an aggregated bannerCount 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 };

AdminVariantListItem (variant picker)

type AdminVariantListItem = {
  id: string;
  productId: string;
  productTitle: string;
  sku: string | null;
  thumbnail: string | null;
  price: number | null;                    // subunits
};

Catalog request

Vendor-submitted taxonomy proposals.

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
};

type CategoryRequest = CatalogRequest & { parentId: string | null };

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": { "total": 412, "items": 20, "perPage": 20, "currentPage": 1, "lastPage": 21 }
}

When selectedIds is absent, pinned is [].


Products

Base path: /admin/products. Reads only — vendors own product writes.

GET /admin/products — List products

Required permission: product: view. Cross-vendor list with filters and full-text search.

Query

NameTypeDefaultNotes
qstring?Free-text search (trimmed, min 1). Case-insensitive substring match across the product's title, slug, subtitle and description, plus any non-deleted variant's sku, barcode, ean or upc. Identical field set on GET /vendor/products
vendorIdstring?Filter to one vendor
brandIdstring?Filter to one brand
primaryCategoryIdstring?Filter on the primary category
categoryIdstring?Match any of the product's categories
tagIdstring?Match any of the product's tags
ingredientIdstring?Match any ingredient
status"draft" | "active" | "archived"?Product status
visibility"public" | "private"?Product visibility
createdFrom, createdToISO datetime?createdAt range
publishedFrom, publishedToISO datetime?publishedAt range
sortBy"createdAt" | "updatedAt" | "publishedAt" | "title" | "vendorName""updatedAt"
sortDirection"asc" | "desc""desc"
limitint1001..500
offsetint0>= 0
selectedIdscsv or string[]?Pre-pin rows for the picker envelope

Response 200 — picker envelope. Items shape is the service-defined admin product list row (vendor info, brand, status, etc.), plus soldLastMonth: { period, actualUnits, displayUnits } | null — see product-sold-metrics.md. null only if that plugin is removed; otherwise always populated (unlike the storefront field, this is not gated by the sold_last_month.enabled setting — admins see the real number regardless). Detail-shaped via GET /:id/detail.


GET /admin/products/:id/detail — Product detail

Required permission: product: view. Returns the product with vendor info, options, variants, tabs, and full taxonomy joins. Same shape the vendor product detail endpoint returns.

Response 200ProductDetail (vendor info + options + variants + tabs + categories/tags/ingredients arrays), plus soldLastMonth: { period, actualUnits, displayUnits } | null (see product-sold-metrics.md).

Errors

StatusCodeWhen
404NOT_FOUNDUnknown product id

Variants — cross-product picker

GET /admin/variants — Variant picker

Required permission: product: view. Flat list across every vendor's products. Standard QueryDto (page / limit / search / selectedIds). Search runs across product title and variant SKU.

Response 200 — picker envelope of AdminVariantListItem[]. See Picker responses.


Taxonomy CRUD (brand / category / tag / ingredient)

The four taxonomies share an almost-identical CRUD surface. Brand is the canonical example; per-taxonomy variations are listed at the end.

Base paths:

TaxonomyBase path
Brand/admin/catalog/brands
Category/admin/catalog/categories
Tag/admin/catalog/tags
Ingredient/admin/catalog/ingredients

Per-taxonomy permissions:

ResourceReadCreateUpdateDeleteApprove
brandbrand: readbrand: createbrand: updatebrand: deletebrand: approve
categorycategory: readcategory: createcategory: updatecategory: deletecategory: approve
tagtag: readtag: createtag: updatetag: deletetag: approve
ingredientingredient: readingredient: createingredient: updateingredient: deleteingredient: approve

GET /admin/catalog/<taxonomy> — List

Required permission: read. Standard QueryDto (page / limit / search / filters[] / selectedIds).

Response 200 — picker envelope of AdminCatalogItemResponse[] (items + pinned, with bannerCount).


GET /admin/catalog/categories/tree — Category tree (category only)

Required permission: category: read. Returns the full nested category tree (parent → children).

Response 200 — nested CatalogItemResponse[].


GET /admin/catalog/<taxonomy>/:id — Get one

Required permission: read.

Response 200CatalogItemResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/catalog/<taxonomy> — Create

Required permission: create.

Body (brand/tag/ingredient)

{
  "title": "L'Oréal",
  "description": "...",                        // optional, max 2000
  "slug": "loreal",
  "image": "brands/loreal.png",                // optional
  "metadata": { "country": "FR" },             // optional
  "isActive": true
}
FieldTypeConstraints
titlestring1..255
descriptionstring | null?max 2000
slugstring1..255, lowercase alnum + hyphens (/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
imagestring | null?Storage key (free-form)
metadataRecord<string, unknown> | null?Free-form
isActivebooleanDefault true

Category-only extras

FieldTypeNotes
parentIdstring | null?Parent category (omit/null for a root)
sortOrderint>= 0. Default 0

Response 201CatalogItemResponse.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
409UNIQUE_VIOLATIONSlug already in use by a non-deleted row

PUT /admin/catalog/<taxonomy>/:id — Update

Required permission: update. Partial — every field is optional.

Response 200CatalogItemResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409UNIQUE_VIOLATIONNew slug collides with a non-deleted row

DELETE /admin/catalog/<taxonomy>/:id — Soft-delete

Required permission: delete.

Response 200 — the deleted CatalogItemResponse.


POST /admin/catalog/<taxonomy>/:id/restore — Restore

Required permission: update.

Response 200 — restored CatalogItemResponse.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id
409UNIQUE_VIOLATIONOriginal slug has been re-used while soft-deleted

Vendor catalog request approvals

Vendors submit new-taxonomy proposals via the vendor-side endpoints; admins review them through these endpoints.

Base path: /admin/catalog/<taxonomy>/requests. Same taxonomy table above.


GET /admin/catalog/<taxonomy>/requests/all — List requests

Required permission: read. Standard QueryDto.

Response 200 — paginated envelope of CatalogRequest[].


GET /admin/catalog/<taxonomy>/requests/:id — Get a request

Required permission: read.

Response 200CatalogRequest.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id

POST /admin/catalog/<taxonomy>/requests/:id/approve — Approve

Required permission: approve. Transitions the request to approved, creates the canonical taxonomy row from the request's fields, and stamps approvedAt + resultingItemId. The request session's user is recorded as the approver.

Response 200 — the approved CatalogRequest (with resultingItemId populated).

Errors

StatusCodeWhen
404NOT_FOUNDRequest not found or not in pending

POST /admin/catalog/<taxonomy>/requests/:id/reject — Reject

Required permission: approve.

Body

{ "reason": "Duplicate of brand 'Existing Brand'" }
FieldTypeConstraints
reasonstring1..2000

Response 200 — the rejected CatalogRequest.

Errors

StatusCodeWhen
400VALIDATION_ERROREmpty / oversized reason
404NOT_FOUNDRequest not found or not in pending

Domain events

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

EventFired when
catalog.brand.created / .updated / .deletedAdmin brand writes
catalog.category.*, .tag.*, .ingredient.*Admin taxonomy writes
catalog.request.approved / .rejectedAdmin approval flow

  • admin-rbac — gates every endpoint via the per-taxonomy permission strings above. See admin-rbac.md.
  • banner — banner rows reference the four taxonomy tables; bannerCount on the admin list comes from this join. See banner.md.
  • search — consumes the catalog.* events to keep the product index in sync.
  • order — order line items snapshot hsnCode from the variant. See order.md.

On this page