Happy House - Ecommerce Docs
Developer ResourcesProducts

Products API Reference

Complete API contracts for the Products module, including routes, auth, DTOs, responses, errors, examples, and integration notes.

Products - API Reference

Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: Admin, storefront and job-facing APIs owned by the Products module, plus the tag surface owned by catalog.

1. Documentation Evidence

AreaFiles InspectedWhat Was Verified
Controllersadmin/product/product-admin.controller.ts, product-admin-bulk.controller.ts, admin/job/product-job-admin.controller.ts, customer/product/product-customer.controller.ts, customer/discovery/product-discovery-customer.controller.ts, catalog/{admin,customer}/tag/*.controller.tsRoutes, methods, guards, decorators, status codes, route ordering
DTOsdto/*.ts under each leafRequest, query, response, validation, examples
Servicesproduct-write.service.ts, product-write-facets.service.ts, product-bulk.service.ts, product-admin.service.ts, product-customer.service.ts, product-discovery-customer.service.ts, product-job-admin.service.ts, catalog-tag-*.service.tsBehavior, side effects, response mapping, errors
Schemapackages/db/src/schema/products/*.ts, packages/db/src/schema/catalog/tag.tsIDs, enums, persisted fields, constraints
Jobs/cachepackages/jobs/src/index.ts, cache-invalidation.tags.tsQueue names, payloads, cache domains
Error registryapps/api/src/common/types/error-codes.tsPRODUCT_*, TAG_*, PAGINATION_*, INVENTORY_* codes

2. Module Summary

FieldValue
Module nameproducts (tags owned by catalog)
Module slugproducts
Primary actorsguest, admin, worker, internal system, blog author
API surfacesadmin, mobile (storefront)
Base route prefixes/api/admin/products, /api/mobile/products, /api/admin/catalog/tags, /api/mobile/catalog/tags
Auth model@Public() storefront; JwtAuthGuard + RoleGuard admin
PersistencePostgreSQL (product, product_slug, product_tag_link, blog_post_product, tag), Redis (cache), BullMQ via outbox
Runtime source of truthPostgreSQL tables
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
publicIdThe exposed UUID v7 identifier of a product/tag. The integer PK never leaves the service layerschemaAll routes
slugRoute-safe URL key owned by product_slug; current + retired under one uniqueslug-ownership.service.tsStorefront :slug routes
canonicalSlugThe current slug returned when a request resolved via a retired onecustomer serviceDetail responses
statusLifecycle: draft | published | unlisted | archivedproduct-status enumLifecycle routes, filters
stockStatusin_stock | out_of_stock | low_stock | pre_orderproduct-stock-status enumStock routes, filters
versionOptimistic lock counter; every write bumps itproduct schemaAll admin mutations
cursorOpaque keyset pagination token on (sortKey, id)compound-cursor.util.tsStorefront lists, feeds
inventory groupReserved null before inventory landed; now populated by the inventory moduleproduct-response.builder.tsProduct responses
promotionsReserved null — additive moduleproduct-response.builder.tsProduct responses
Idempotency-KeyRequired header on creates/bulk/job submitsidempotency.interceptor.tsMutating routes

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/admin/productsAdminJWT+RoleProducts_READProductAdminControllerOffset-paginated admin list
AdminGET/api/admin/products/:publicIdAdminJWT+RoleProducts_READProductAdminControllerAdmin detail
AdminPOST/api/admin/productsAdminJWT+RoleProducts_CREATEProductAdminControllerCreate (201)
AdminPATCH/api/admin/products/:publicIdAdminJWT+RoleProducts_UPDATEProductAdminControllerGeneral update
AdminPATCH/api/admin/products/:publicId/lifecycleAdminJWT+RoleProducts_UPDATEProductAdminControllerLifecycle transition
AdminPATCH/api/admin/products/:publicId/stockAdminJWT+RoleProducts_UPDATEProductAdminControllerStock status (narrowed for tracked)
AdminPATCH/api/admin/products/:publicId/pricingAdminJWT+RoleProducts_UPDATEProductAdminControllerPricing
AdminPATCH/api/admin/products/:publicId/mediaAdminJWT+RoleProducts_UPDATEProductAdminControllerMedia
AdminPATCH/api/admin/products/:publicId/seoAdminJWT+RoleProducts_UPDATEProductAdminControllerSEO
AdminPATCH/api/admin/products/:publicId/discoveryAdminJWT+RoleProducts_UPDATEProductAdminControllerDiscovery flags
AdminPATCH/api/admin/products/:publicId/tagsAdminJWT+RoleProducts_UPDATEProductAdminControllerTag membership replace
AdminDELETE/api/admin/products/:publicIdAdminJWT+RoleProducts_DELETEProductAdminControllerSoft delete (200, message-only)
AdminPOST/api/admin/products/:publicId/restoreAdminJWT+RoleProducts_RESTOREProductAdminControllerRestore (200)
AdminPOST/api/admin/products/bulk/deleteAdminJWT+RoleProducts_DELETEProductAdminBulkControllerBulk soft delete
AdminPOST/api/admin/products/bulk/restoreAdminJWT+RoleProducts_RESTOREProductAdminBulkControllerBulk restore
AdminPOST/api/admin/products/bulk/lifecycleAdminJWT+RoleProducts_UPDATEProductAdminBulkControllerBulk lifecycle
AdminPOST/api/admin/products/bulk/discoveryAdminJWT+RoleProducts_UPDATEProductAdminBulkControllerBulk discovery flags
AdminPOST/api/admin/products/jobs/importAdminJWT+RoleProducts_CREATE + service pairProductJobAdminControllerImport job
AdminPOST/api/admin/products/jobs/exportAdminJWT+RoleProducts_READProductJobAdminControllerExport job
AdminPOST/api/admin/products/jobs/:publicId/cancelAdminJWT+RoleProducts_UPDATEProductJobAdminControllerCancel job
AdminGET/api/admin/products/jobsAdminJWT+RoleProducts_READProductJobAdminControllerJob list
AdminGET/api/admin/products/jobs/:publicIdAdminJWT+RoleProducts_READProductJobAdminControllerJob detail
AdminGET/api/admin/catalog/tagsAdminJWT+RoleTags_READCatalogTagAdminControllerTag list
AdminGET/api/admin/catalog/tags/:publicIdAdminJWT+RoleTags_READCatalogTagAdminControllerTag detail
AdminPOST/api/admin/catalog/tagsAdminJWT+RoleTags_CREATECatalogTagAdminControllerCreate tag (201)
AdminPATCH/api/admin/catalog/tags/:publicIdAdminJWT+RoleTags_UPDATECatalogTagAdminControllerUpdate tag
AdminPATCH/api/admin/catalog/tags/:publicId/visibilityAdminJWT+RoleTags_UPDATECatalogTagAdminControllerVisibility
AdminDELETE/api/admin/catalog/tags/:publicIdAdminJWT+RoleTags_DELETECatalogTagAdminControllerSoft delete (200, message-only)
AdminPOST/api/admin/catalog/tags/:publicId/restoreAdminJWT+RoleTags_RESTORECatalogTagAdminControllerRestore (200)
MobileGET/api/mobile/productsGuest@Public()ProductCustomerControllerFiltered list + cursor
MobileGET/api/mobile/products/searchGuest@Public()ProductCustomerControllerSearch + filters + cursor
MobileGET/api/mobile/products/suggestionsGuest@Public()ProductCustomerControllerAutocomplete (≤10)
MobileGET/api/mobile/products/:slugGuest@Public()ProductCustomerControllerDetail by slug
MobileGET/api/mobile/products/discovery/:feedGuest@Public()ProductDiscoveryCustomerControllerDiscovery feeds
MobileGET/api/mobile/catalog/tagsGuest@Public()CatalogTagCustomerControllerVisible tags
MobileGET/api/mobile/catalog/tags/:slugGuest@Public()CatalogTagCustomerControllerTag by slug

Route ordering is load-bearing: literal segments (search, suggestions, bulk, import, export) are declared before :slug/:publicId routes, or the param routes swallow them (verified live: unauthenticated bulk returns 401, not 400).

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
Storefront@Public() + IpThrottlerGuardNoneN/AYesRate limits: PUBLIC_READ 60/min, PUBLIC_SEARCH 60/min (ip+device), PUBLIC_SUGGEST 300/min (ip+device), PUBLIC_HIGH_FREQUENCY 300/min (feeds)
AdminJwtAuthGuard, RoleGuardreq.user (id, role)Products_* / Tags_*Nosuperadmin bypasses; permissions resolved via RoleService.getPermissionsForRoleName
Import submitroute Products_CREATE + service re-checkreq.userProducts_CREATE and Products_UPDATENo403 PRODUCT_JOB_ENTITY_PERMISSION_DENIED without the pair

Idempotency (Idempotency-Key header): create (product), restore (product-restore), bulk (product-bulk), job import (product-job-import), job export (product-job-export). Missing → 400 IDEMPOTENCY_KEY_REQUIRED; different payload same key → 409 IDEMPOTENCY_KEY_CONFLICT; in-flight → 409 IDEMPOTENCY_REQUEST_IN_PROGRESS.

6. DTO and Model Reference

6.1 CreateProductDto (body of POST /api/admin/products)

FieldTypeRequiredDefaultValidationExampleSource
namestringYesN/A@IsString, 1..255"Grey Linen Sofa"create-product.dto.ts
skustringNoNULLmax 64"SKU-0001"
slugstringNogeneratedslug regex, max 260"grey-linen-sofa"
shortDescriptionstringNoNULLmax 500"A three-seater in grey linen"
descriptionstringNoNULL"Full description..."
categoryPublicIdUUID v7YesN/A@IsUUID("7")018f4e2a-…
brandPublicIdUUID v7NoNULL@IsUUID("7")018f4e2a-…
brandSeriesPublicIdUUID v7NoNULL@IsUUID("7")018f4e2a-…series must match brand
mrpnumberYesN/A@IsInt, >= 0, <= MAX_SAFE_INTEGER100000minor units
sellingPricenumberYesN/Asame80000minor units; <= mrp
versionnumberNoN/A>= 11optimistic lock

6.2 Facet PATCH DTOs

DTOFieldsValidation
UpdateProductLifecycleDtostatus (enum), version@IsIn(productStatusEnum)
UpdateProductStockDtostockStatus (enum), version@IsIn(productStockStatusEnum)
UpdateProductPricingDtomrp, sellingPrice, versionminor units; sellingPrice <= mrp
UpdateProductMediaDtothumbnailKey, thumbnailAlt, gallery[] (key/alt/order), videos[], attachments[]caps 30/10/20; alt requires key; each videos[] item sets exactly one of key (storage key, <video src>) or embedUrl (absolute https:// on the host allowlist, <iframe>) — neither or both is 400 PRODUCT_VIDEO_SOURCE_REQUIRED / PRODUCT_VIDEO_SOURCE_CONFLICT. Allowlist: www.youtube.com, www.youtube-nocookie.com, player.vimeo.comyoutu.be is not accepted
UpdateProductSeoDtoSEO metadata fields
UpdateProductDiscoveryDtofeatured, trending, bestSeller, newArrivalbooleans
UpdateProductTagsDtotagPublicIds[]max 25 tags

6.3 Bulk DTOs

ProductBulkIdsDto { publicIds: string[] } (non-empty, @IsUUID("7", { each: true })); ProductBulkLifecycleDto adds status; ProductBulkDiscoveryDto adds the four flags. Result: { succeeded: string[], failures: [{ publicId, errorCode }] }.

6.4 Storefront query DTOs

DTOFields
ProductStorefrontFilterDto (base)categorySlug, brandSlug, seriesSlug, tags[], stockStatus[], minPrice, maxPrice, onSale, featured, trending, bestSeller, newArrival, q (max 100), cursor, limit (1..100, default 20) — onSale=true is a union of catalogue markdown and any product covered by a live special-deal campaign, not a single price comparison; see Backend §9.1
FetchProductsDtobase + sort in newest | priceAsc | priceDesc | discount (default newest)
SearchProductsDtobase + sort in list sorts + relevance
FetchProductDiscoveryDtocursor, limit only
ProductSuggestionsQueryDtoq, scoping param

7. Enum Reference

EnumValueMeaningRuntime EffectSource
product_statusdraftNot customer-visibleExcluded from every storefront readenums.ts
product_statuspublishedIn listings, search, feeds; indexableOnly status with robots_index = true
product_statusunlistedDirect URL onlyDetail-only; not indexable; purchasable
product_statusarchivedRetired, admin-visible, read-onlyOnly transition out: → draft
product_stock_statusin_stock / out_of_stock / low_stock / pre_orderAvailability projectionpre_order narrowing via inventory rules
product_job_kindimport / exportJob kindPermissions differ
product_job_statusqueued / processing / completed / failed / cancelledJob lifecycleLease claim, cooperative cancel

8. Endpoint Reference

8.1 GET /api/mobile/products

Purpose

The storefront listing. Called for category/brand landing pages, tag pages and filtered browsing; paginate with nextCursor.

Source Evidence

EvidencePath
Controllercustomer/product/product-customer.controller.ts
DTOcustomer/product/dto/fetch-products.dto.ts, product-storefront-filter.dto.ts, shared/product-card-response.dto.ts
Serviceshared/product-storefront-query.service.ts, product-storefront-filter-resolver.service.ts, shared/product-card-assembler.service.ts
Schemapackages/db/src/schema/products/product.ts

Auth and Permissions

  • Auth: none — @Public()
  • Guard chain: IpThrottlerGuard
  • Permission: N/A
  • Guest support: yes
  • Rate limit: PUBLIC_READ 60/min
  • Idempotency: N/A (GET)

Request

PartRequiredDetails
QueryNoAny subset of the filter DTO + sort + cursor + limit

Response

200 — envelope with data (product card array), errorCode: null, nextCursor (string or null).

This route returns the card shape, not the detail shape. Every listing surface — this route, /search and /discovery/:feed — is a grid, and a grid renders a thumbnail, a name, a price, a brand and a stock badge. Sending the detail response per row shipped a gallery, videos, attachments, specifications, SEO metadata and tags for products nobody had opened, and cost six extra database reads per page plus one signed storage URL per media item rather than per product.

Every field a card does carry keeps the exact path and meaning it has on GET /:slug, so one renderer serves both — pricing.sellingPrice and media.thumbnail.url mean the same thing on either response. ProductCardShape is declared as a Pick<> over the detail shape, so renaming or removing a detail member breaks the card type at compile time — but a member added to the detail shape and never picked fails nothing, which is the one kind of drift the Pick<> tie does not catch.

The pricing block, since variants and special deals

mrp and sellingPrice are rollups — the LOWEST across the product's live, active variants. They are what every price index and every sort reads, and they are the "from Rs X" a card renders. maxSellingPrice is the upper bound, isPriceRange is maxSellingPrice > sellingPrice, and variantCount is 1 when the storefront should render no picker.

FieldRender it as
effectivePriceThe price. What the customer pays
sellingPriceThe struck-through "was" figure — only when deal is non-null
deal.effectiveMaxPriceThe upper end of the band, when isPriceRange
deal.discountBpsA badge. 2000 is 20%. Never arithmetic

variants[] — DETAIL ONLY

GET /api/mobile/products/{slug} carries the configurations the customer may choose between, cheapest position first. A listing card does not: twenty cards times each product's variant count would multiply the hottest read in the system to populate a picker no card renders, and pricing.variantCount is the one bit a card needs — it decides between "Add to cart" and "View options".

FieldRender it as
idproduct_variant.public_id. Send this as variantPublicId on an add, and use it as the path segment of PUT/DELETE /cart/items/{id}
nameThe option label. null means the variant IS the product — render the product name alone
isDefaultWhich one an add naming no variant resolves to. Open the picker on it
pricing.effectivePriceWhat choosing THIS configuration costs. Never product.pricing.sellingPrice, which is the rollup
pricing.mrpThe variant's own struck-through figure
availability.inStockfalse renders the option disabled, not hidden — the customer may still want to see that it exists
availability.availableQuantityUnits purchasable. null when untracked — never 0

Withdrawn and soft-deleted variants are absent, not disabled: they are no longer offered at all. Always at least one entry for a live product; an empty array means every configuration has been withdrawn and the product is unbuyable.

"variants": [
  {
    "id": "019fc692-…-00aa",
    "name": "512GB / Blue",
    "sku": "iphone-15-512-blue",
    "isDefault": false,
    "pricing": {
      "mrp": 10000000, "sellingPrice": 9000000, "effectivePrice": 8100000,
      "currency": "NPR",
      "deal": { "basePrice": 9000000, "effectivePrice": 8100000, "saving": 900000, "discountBps": 1000 }
    },
    "availability": { "inStock": true, "availableQuantity": 9 }
  }
]

All money is integer minor units (paisa), as everywhere else on this surface. A consumer divides by 100 exactly once, at its own boundary.

deal is null when no campaign covers the product, and effectivePrice then equals sellingPrice. A campaign is applied at READ time and never written to a price column, so cancelling one takes effect immediately.

Three rules, and each of them has a failure mode behind it:

  1. Never total or charge from sellingPrice. It is the cheapest variant's price. On a cart line the charged figure is pricing.unitPrice, which is that line's own variant.
  2. saving + effectivePrice === basePrice exactly. The backend computes the saving and subtracts it precisely so a receipt's two numbers reconstruct the original. Recomputing either from discountBps differs by a paisa at rounding boundaries.
  3. Render the band as effectivePricedeal.effectiveMaxPrice. Pairing the discounted floor with the undiscounted maxSellingPrice shows a top figure nobody is charged.

A campaign does not reorder a price-sorted listing — sorting reads the rollup, because it needs an index. A stated, accepted limitation.

For the gallery, specifications, tags, SEO, description or timestamps, read the detail route (8.4).

{
  "message": "Products fetched successfully",
  "data": [
    {
      "basic": { "id": "018f4e2a-7b3c-7c1e-9b2a-3d4e5f6a7b8f", "name": "Grey Linen Sofa", "slug": "grey-linen-sofa" },
      "pricing": {
        "mrp": 100000, "sellingPrice": 80000, "maxSellingPrice": 120000,
        "isPriceRange": true, "variantCount": 3,
        "discount": 20000, "discountPercentage": 20, "currency": "NPR",
        "effectivePrice": 64000,
        "deal": { "basePrice": 80000, "effectivePrice": 64000, "saving": 16000, "discountBps": 2000, "effectiveMaxPrice": 96000 }
      },
      "classification": { "brand": { "publicId": "018f4e2a-…", "name": "Acme Furniture", "slug": "acme-furniture" } },
      "media": { "thumbnail": { "url": "/public/sofa.jpg", "alt": "a grey sofa", "expiresAt": null } },
      "status": { "lifecycle": "published", "saleStatus": "on_sale", "stockStatus": "in_stock", "purchasable": true },
      "inventory": { "stockStatus": "low_stock", "isLowStock": true, "isOutOfStock": false, "isTracked": true, "available": 3 }
    }
  ],
  "errorCode": null,
  "nextCursor": "eyJzb3J0IjoibmV3ZXN0IiwidmFsdWUiOiIxNzQ2..."
}

Notes on the fields that are easy to misread:

  • status.purchasable is the single owner of "can this be bought". Do not re-derive it from inventory — that answers how many there are, which is a different question, and is null for a product with no inventory row.
  • classification.brand is the only classification a card carries, and it is null for a product with no brand. Category, brand series and tags are detail-response fields.
  • status.visibility is absent — it is derived from lifecycle, and a listing only ever emits published rows.
  • rating is absent, deliberately. A rating per card is a join per page. Ask the Reviews module for the summaries a page needs in one call.
  • media.thumbnail falls back to the lowest-order gallery image when the product has no explicit thumbnail. The rest of the gallery is neither included nor signed.

Side Effects

None — read-only; Redis cache on the resolved key.

Error Cases

HTTPCodeCondition
400PAGINATION_CURSOR_INVALIDCursor minted by a different sort
404category/brand/series not-foundUnknown classification slug

Example Requests

GET /api/mobile/products?categorySlug=sofas&tags=best-seller&sort=priceAsc&limit=20 HTTP/1.1

8.2 GET /api/mobile/products/search

Same contract as 8.1 — including the card shape — plus sort=relevance; term < 2 chars is treated as no search. Rate limit PUBLIC_SEARCH 60/min. Ranking is similarity(name, term) DESC, id DESC.

8.3 GET /api/mobile/products/suggestions

200 with at most 10 items { entityType-less: publicId, name, slug }; no pagination. Rate limit PUBLIC_SUGGEST 300/min.

8.4 GET /api/mobile/products/:slug

Purpose

Product detail. Accepts a current or retired slug; unlisted products resolve here only.

Auth and Permissions

@Public(), PUBLIC_READ 60/min.

Response

200 — single grouped product, the full detail shape (customer surface: no version, no metadata). basic.slug is always the current slug.

This is the only storefront product route that returns the detail shape, and deliberately so: a product page renders the gallery, the specifications and the SEO block. It also carries rating and canonicalSlug, neither of which appears on a listing.

Error Cases

HTTPCodeCondition
404PRODUCT_NOT_FOUNDMissing, hidden, deleted, draft or archived — on both slug branches

Edge Cases

Retired slug → 200 with current slug (frontend redirects; API does not 3xx). Hidden/deleted → 404. unlisted → 200 here, absent everywhere else.

8.5 GET /api/mobile/products/discovery/:feed

feed in featured | trending | best-seller | new-arrival | on-sale. 200 with cursor-paginated items in the same card shape as 8.1; rate limit PUBLIC_HIGH_FREQUENCY 300/min. Unknown feed → 400 PRODUCT_INVALID_DISCOVERY_FEED. The feed itself is the filter — only cursor/limit query params.

The discovery flags themselves are not on the response. The feed you called already says which one is set, and no grid renders the other three.

8.6 POST /api/admin/products

Purpose

Create a product as draft with an inventory row, slug and SEO link in one transaction.

Auth and Permissions

  • Auth: bearer token
  • Guard chain: JwtAuthGuard, RoleGuard, IpThrottlerGuard
  • Permission: Products_CREATE
  • Rate limit: ADMIN_WRITE 10/min
  • Idempotency: Idempotency-Key (scope product)

Request

Body per §6.1 (minimal: { "name", "categoryPublicId", "mrp", "sellingPrice" }).

Response

201 — envelope with the grouped product (admin surface: version, metadata).

Side Effects

product + product_slug + inventory (untracked) + optional seo rows in one transaction; activity record; cache invalidation (product/tag/inventory domains).

Error Cases

HTTPCodeCondition
400IDEMPOTENCY_KEY_REQUIREDMissing header
404PRODUCT_CATEGORY_NOT_FOUND / PRODUCT_BRAND_NOT_FOUND / PRODUCT_SERIES_NOT_FOUNDUnknown classification reference
409PRODUCT_SKU_ALREADY_EXISTSLive row holds the SKU
409PRODUCT_SERIES_BRAND_MISMATCHSeries belongs to a different brand
409PRODUCT_SERIES_REQUIRES_BRANDSeries without brand
409IDEMPOTENCY_KEY_CONFLICT / IDEMPOTENCY_REQUEST_IN_PROGRESSKey misuse

8.7 PATCH /api/admin/products/:publicId

General update; cannot change lifecycle/stock/pricing/media/seo/discovery/tags (each has its own endpoint). 409 PRODUCT_VERSION_CONFLICT on stale version; 409 PRODUCT_ALREADY_DELETED on a deleted row; 409 PRODUCT_ARCHIVED_NOT_EDITABLE while archived. 200 with grouped response.

8.8 PATCH /api/admin/products/:publicId/lifecycle

See Features and flows §7. Errors: 409 PRODUCT_INVALID_STATUS_TRANSITION, PRODUCT_THUMBNAIL_REQUIRED_TO_PUBLISH, PRODUCT_ARCHIVED_NOT_EDITABLE, PRODUCT_VERSION_CONFLICT. Side effects: product.status, seo.robots_index, activity, cache.

8.9 PATCH /api/admin/products/:publicId/stock

Purpose

Set the product stock status. Narrowed for tracked products — the column belongs to inventory once tracking is on.

Request

{ "stockStatus": "in_stock", "version": 3 }

Error Cases

HTTPCodeCondition
409INVENTORY_STOCK_STATUS_DERIVEDTracked product, non-pre_order request
409INVENTORY_PRE_ORDER_REQUIRES_OVERSELLpre_order without inventory oversell
409PRODUCT_VERSION_CONFLICTStale version

Leaving pre_order on a tracked product re-derives the status from inventory counters; untracked products keep the unrestricted set.

8.10 PATCH /:publicId/{pricing,media,seo,discovery,tags}

Same envelope, permission and rate limit as the general PATCH; payloads per §6.2. Side effects: row update + version bump + activity + cache invalidation. Errors: 409 PRODUCT_VERSION_CONFLICT, PRODUCT_ARCHIVED_NOT_EDITABLE, PRODUCT_ALREADY_DELETED, media caps (PRODUCT_GALLERY_LIMIT_EXCEEDED 30, PRODUCT_ATTACHMENT_LIMIT_EXCEEDED 20, PRODUCT_VIDEO_LIMIT_EXCEEDED 10), PRODUCT_TAG_NOT_FOUND, PRODUCT_TAG_LIMIT_EXCEEDED (25).

8.11 DELETE /api/admin/products/:publicId

200 message-only body ({ message, errorCode }, no data). 404 PRODUCT_NOT_FOUND; 409 PRODUCT_ALREADY_DELETED. Soft delete only — hard delete is structurally unsupported (product_slug RESTRICT).

8.12 POST /api/admin/products/:publicId/restore

200 with grouped response. 409 PRODUCT_NOT_DELETED. Idempotency-Key (scope product-restore).

8.13 POST /api/admin/products/bulk/*

Four bulk routes; 200 with { succeeded, failures }; cap 100 unique ids (409 PRODUCT_BULK_LIMIT_EXCEEDED); rate limit ADMIN_BULK_WRITE 5/min; Idempotency-Key (scope product-bulk). Bulk delete/restore guard per item (PRODUCT_NOT_FOUND, PRODUCT_ALREADY_DELETED, PRODUCT_NOT_DELETED); bulk lifecycle validates each transition; bulk discovery sets the four flags.

8.14 POST /api/admin/products/jobs/import

multipart/form-data (file + entity). Requires Products_CREATE at the route and Products_CREATE + Products_UPDATE in the service. Idempotency-Key (scope product-job-import); rate limit ADMIN_ASYNC_JOB_SUBMIT 10/hour. Errors: 400 PRODUCT_IMPORT_FILE_INVALID / PRODUCT_IMPORT_FILE_TOO_LARGE / IDEMPOTENCY_KEY_REQUIRED; 403 PRODUCT_JOB_ENTITY_PERMISSION_DENIED / PERMISSION_ROLE_NOT_ASSIGNED; 409 idempotency conflicts. Returns 200 with the queued job.

8.15 POST /api/admin/products/jobs/export

JSON body { entity, includeDeleted?, search? }. Permission Products_READ; Idempotency-Key (scope product-job-export). Returns the queued job; a filter set matching > 50,000 rows fails the job (PRODUCT_EXPORT_TOO_LARGE).

8.16 POST /api/admin/products/jobs/:publicId/cancel

200 with the job row; 404 PRODUCT_JOB_NOT_FOUND; 409 PRODUCT_JOB_NOT_CANCELLABLE from a terminal state. Cooperative — a processing import aborts and rolls back.

8.17 GET /api/admin/products/jobs(/:publicId)

List (paginated, filters kind/entity/status, ordered by createdAt) and detail (adds startedAt, isStalled, retained errors). Permission Products_READ, ADMIN_READ 30/min.

8.18 GET /api/admin/products/jobs/import-template

Downloads the column template for an import file — header row only, no example row, because a downloadable import file is eventually uploaded unmodified and a data row would create junk. Zero rows is a no-op.

entity is product — required even though it is the only value, so the three template routes read alike.

Permission Products_READ — the file carries no data, only column names. Rate limit ADMIN_READ 30/min. Returns 200 as text/csv; charset=utf-8 with Content-Disposition: attachment. An unknown entity fails DTO validation with 400.

Columns: sku, name, categoryPublicId, brandPublicId, brandSeriesPublicId, mrp, sellingPrice, shortDescription, description. The classification columns are publicIds, unlike catalog import which links by slug.

The list is generated from the same constant the row builder's fields are typed against, so a column cannot silently diverge from what the parser reads: satisfies rejects a column that is not a row field, an Exclude assertion rejects a row field with no column, and a round-trip spec parses the emitted template back through the real builder.

Route ordering is load-bearing — this literal segment is declared above @Get(":publicId") in the controller. Declared after it, the wildcard swallows the path and the request fails as "publicId must be a UUID".

8.19 Tag endpoints (/api/admin/catalog/tags, /api/mobile/catalog/tags)

Admin: list/detail/create(201)/update/visibility/delete(200 message-only)/restore(200). Permissions Tags_*, rate limits ADMIN_READ/ADMIN_WRITE. Errors: 404 TAG_NOT_FOUND; 409 TAG_SLUG_ALREADY_EXISTS, TAG_ALREADY_DELETED, TAG_NOT_DELETED, TAG_HAS_LINKED_PRODUCTS (delete while linked). Storefront: GET /api/mobile/catalog/tags (visible tags), GET /:slug (404 TAG_NOT_FOUND), PUBLIC_READ 60/min.

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence (admin mutation)

9.3 Error Branch (lifecycle)

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/mobile/productskeyset cursor (sortKey, id)20100newest, priceAsc, priceDesc, discountcategorySlug (descendants), brandSlug, seriesSlug, tags (AND), min/maxPrice, stockStatus, onSale, 4 flags, qper page
GET /api/mobile/products/searchkeyset cursor20100list sorts + relevancesameper page
GET /api/mobile/products/discovery/:feedkeyset cursor20100newest (feed ordering)none (feed is the filter)per page
GET /api/admin/productsoffset (page/size)20100sort/order on admin columnsstatus, includeDeleted, categoryId, brandId, seriesId, tagIds, price, stock, flags, searchoffset cap 10,000

Cursor behavior: opaque; sort-bound (400 PAGINATION_CURSOR_INVALID on mismatch); nextCursor: null = last page; over-fetch +1 instead of COUNT.

11. Caching, Jobs, and External Integrations

IntegrationUsed?DetailsSource
Redis cacheYesProduct + tag cache domains; CACHE_TTL.STANDARD (300s); all keys under PRODUCT_CACHE_PREFIX = product:v2:, still inside the product:* invalidation glob; cleared by product/tag writes. The v2 segment versions the SERIALIZED shape — bump it whenever a product read's response shape changes, or warm keys serve the old shape for a full TTL after deploycache-invalidation.tags.ts, shared/products.constants.ts
BullMQYesPRODUCTS queue: product.import_entities, product.export_entities; enqueued exclusively via the transactional outbox; PRODUCTS registered in REGISTERED_QUEUES for defaultJobOptionspackages/jobs/src/index.ts
External APINo

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsDocumented?
GET /api/mobile/productsfindAllFetchProductsDto, ProductCardResponseDtoProductCustomerService.listPublic+IpThrottleproductproduct, product slugs, brand, brand slugs, inventoryCursor 400Yes
GET /api/mobile/products/searchsearchSearchProductsDto, ProductCardResponseDtoProductCustomerService.searchPublic+IpThrottleproductsameYes
GET /api/mobile/products/suggestionssuggestionsProductSuggestionsQueryDtoProductCustomerService.suggestionsPublic+IpThrottleproductproductYes
GET /api/mobile/products/:slugfindBySlugparam, ProductCustomerResponseDtoProductCustomerService.findBySlugPublic+IpThrottleproductslug, product, category, brand, brand series, tags, seo, inventory, rating404Yes
GET /api/mobile/products/discovery/:feedfindFeedFetchProductDiscoveryDto, ProductCardResponseDtoProductDiscoveryCustomerService.feedPublic+IpThrottleproductproduct, product slugs, brand, brand slugs, inventory400 feedYes
GET /api/admin/productsfindAllFetchProductDtoProductAdminService.findAllJWT+Role+IpThrottleProducts_READproduct400 sort/pageYes
GET /api/admin/products/:publicIdfindByIdProductParamsDtoProductAdminService.findByIdJWT+Role+IpThrottleProducts_READproduct404Yes
POST /api/admin/productscreateCreateProductDtoProductWriteService.createJWT+Role+IpThrottle+IdempotencyProducts_CREATEinvalidateproduct, slug, inventory, seo404/409/400Yes
PATCH /api/admin/products/:publicIdupdateUpdateProductDtoProductWriteService.updateJWT+Role+IpThrottleProducts_UPDATEinvalidateproduct409/404Yes
PATCH /:publicId/lifecycleupdateLifecycleUpdateProductLifecycleDtoProductWriteFacetsService.updateLifecyclesameProducts_UPDATEinvalidateproduct, seo409Yes
PATCH /:publicId/stockupdateStockUpdateProductStockDto…updateStocksameProducts_UPDATEinvalidateinventory (FOR SHARE), product409Yes
PATCH /:publicId/pricingupdatePricingUpdateProductPricingDto…updatePricingsameProducts_UPDATEinvalidateproduct409Yes
PATCH /:publicId/mediaupdateMediaUpdateProductMediaDto…updateMediasameProducts_UPDATEinvalidateproduct409 capsYes
PATCH /:publicId/seoupdateSeoUpdateProductSeoDto…updateSeosameProducts_UPDATEinvalidateproduct, seo409Yes
PATCH /:publicId/discoveryupdateDiscoveryUpdateProductDiscoveryDto…updateDiscoverysameProducts_UPDATEinvalidateproduct409Yes
PATCH /:publicId/tagsupdateTagsUpdateProductTagsDto…updateTagssameProducts_UPDATEinvalidateproduct_tag_link404/409Yes
DELETE /api/admin/products/:publicIddeleteProductParamsDtoProductWriteService.deletesameProducts_DELETEinvalidateproduct404/409Yes
POST /:publicId/restorerestoreProductParamsDtoProductWriteService.restoreJWT+Role+IpThrottle+IdempotencyProducts_RESTOREinvalidateproduct404/409Yes
POST /api/admin/products/bulk/* (4)4 methodsBulk DTOsProductBulkService.*JWT+Role+IpThrottle+Idempotencyper routeinvalidateproduct409Yes
POST /api/admin/products/jobs/importsubmitImportSubmitProductImportDtoProductJobAdminService.submitImportJWT+Role+IpThrottle+IdempotencyProducts_CREATE(+UPDATE)outbox → product.import_entitiesproduct_job, outbox_events400/403/409Yes
POST /api/admin/products/jobs/exportsubmitExportSubmitProductExportDto…submitExportsameProducts_READoutbox → product.export_entitiesproduct_job, outbox_events400/409Yes
POST /api/admin/products/jobs/:publicId/cancelcancelProductJobParamsDto…cancelJWT+Role+IpThrottleProducts_UPDATEproduct_job404/409Yes
GET /api/admin/products/jobsfindAllFetchProductJobDto…findAllJWT+Role+IpThrottleProducts_READproduct_jobYes
GET /api/admin/products/jobs/:publicIdfindByIdProductJobParamsDto…findByIdJWT+Role+IpThrottleProducts_READproduct_job404Yes
GET /api/admin/catalog/tagsfindAllFetchCatalogTagDtoCatalogTagAdminService.findAllJWT+Role+IpThrottleTags_READtag400Yes
GET /api/admin/catalog/tags/:publicIdfindByIdCatalogTagParamsDto…findByIdsameTags_READtag404Yes
POST /api/admin/catalog/tagscreateCreateCatalogTagDto…createJWT+Role+IpThrottleTags_CREATEinvalidatetag409Yes
PATCH /api/admin/catalog/tags/:publicIdupdateUpdateCatalogTagDto…updatesameTags_UPDATEinvalidatetag404/409Yes
PATCH /api/admin/catalog/tags/:publicId/visibilitysetVisibilitySetCatalogTagVisibilityDto…setVisibilitysameTags_UPDATEinvalidatetag404/409Yes
DELETE /api/admin/catalog/tags/:publicIddeleteCatalogTagParamsDto…deletesameTags_DELETEinvalidatetag404/409Yes
POST /api/admin/catalog/tags/:publicId/restorerestoreCatalogTagParamsDto…restoresameTags_RESTOREinvalidatetag404/409Yes
GET /api/mobile/catalog/tagsfindAllCatalogTagCustomerService.findAllPublic+IpThrottletagtagYes
GET /api/mobile/catalog/tags/:slugfindBySlugparam…findBySlugPublic+IpThrottletagtag404Yes

13.2 Request/Response Exhaustiveness

Covered in §8: minimal + full create payloads (§6.1/8.6), success responses (§8.1, 8.4), empty-list behavior (data: [], nextCursor: null), validation error (400 IDEMPOTENCY_KEY_REQUIRED representative), domain errors per endpoint (§8 error tables), rate-limit behavior (throttler 429 on exceeding per-surface limits), admin permission errors (403).

13.3 API Diagram Pack

Route ownership (§9.1), sequence per endpoint family (§9.2, backend §7), activity diagrams per mutation family (§9.3, features §6), error decision trees (§9.3), data contract map (backend §5.3 ER + §6 service tables), async/job flow (backend §7.3 import sequence), cache flow (backend §11).

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Web frontendStorefront routes, filter DTO, cursor sort-binding, nextCursor, listings return the card shape and the detail route returns the full one400 PAGINATION_CURSOR_INVALID → restart without cursor; 404 → remove from listingsChanged — listing shape narrowed, see §13.6
Mobile appPUBLIC_READ/PUBLIC_SEARCH/PUBLIC_SUGGEST rate limits, ip+device keys on search/suggest, the card shape on all three listing routes429 → back off; offline retry on GETs safeChanged — listing shape narrowed, see §13.6
Admin panelProducts_* permissions, optimistic version, idempotency headers on creates/bulk/jobs409 PRODUCT_VERSION_CONFLICT → re-read and re-applyStable
QALifecycle transition table, tracked-stock narrowing, retired-slug behaviorReproduce via exact error codesStable
Blog authorsproductIds on post create/update; undefined keeps, [] clearsStorefront detail shows published products onlyStable

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Cursor paginationKeyset on (sortKey, id)Offset pagesStable ordering, no deep-offset costCursor sort-boundLoud 400 on mismatch
Facet PATCH endpointsSeven single-purpose PATCHes + general PATCHOne mega-PATCHEnforces lifecycle/stock/pricing invariants per concernMore routesGrouped response keeps clients stable
promotions/inventory reserved nullAlways-present null keysOmit until neededAdditive modules, no breaking changeConsumers must ignore nullDocumented as reserved
Optimistic lockingversion on every writeLast-write-winsProducts have many concurrently-edited fields409 churnRe-read/retry flow
Storefront literal statusInlined SQL literalBound parameterPartial-index usability (EXPLAIN-proven)Odd-looking codeDocumented, spec-asserted
Card shape on listings, detail shape on /:slugTwo response shapes, one assembler eachOne DTO for both surfaces; or a view=card|full query parameterA listing is a grid and a grid renders six fields. One shape meant six needless reads per page and a signed URL per media item rather than per product — measured at 42,572 → 13,092 bytes for a 20-product page. A view parameter would have doubled the cache keyspace and left both shapes reachable on both routes for no consumer that wanted itTwo shapes could drift, or a client could bind a card to a field only the detail carriesProductCardShape is declared as a Pick<> over ProductResponseShape, so renaming or removing a detail member breaks the card type at compile time — but a member added to the detail shape and never picked fails nothing. ProductCardResponseDto both implements that shape and asserts an equal keyof set against it: implements alone fixes member TYPES, and misses both an OPTIONAL member added to the shape and never declared on the DTO — the version?/metadata? pattern — and a member REMOVED from the shape, either of which is how a Swagger contract goes stale with a green build; the key-set assertion closes both. ProductCustomerResponseDto deliberately has no such assertion, because it legitimately carries rating and canonicalSlug the shape does not. Both builders share the same derivation helpers, so discount, saleStatus and purchasable cannot disagree

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
inventory group populatedWeb/mobile/adminAssembler + inventory serviceNone (was null)NoReserved-null contract made it additive
PATCH /stock 409 for trackedAdmin panelresolveStockStatusForWriteNoneNoUntracked products unchanged
Listing routes narrowed to the card shape (GET /products, /search, /discovery/:feed)Web frontend, mobile app. Admin panel unaffected/api/admin/products is a different surface and is unchanged.New ProductCardAssembler. ProductResponseAssembler untouched, so the /:slug detail and admin paths are unchanged. Cart and wishlist were moved onto the card shape in a follow-up — see their own change-impact notesNone — read-path projection only, no schema or migrationNoBreaking, and shipped without a compatibility window because nothing consumed the removed fields: happy-shop-frontend serves products from local fixtures and has no HTTP client for these routes, and Happy-shop-admin does not call the storefront surface. A consumer that needs a removed field reads the detail route. Re-adding any dropped field later is additive and non-breaking. PRODUCT_CACHE_PREFIX was bumped to product:v2: in the same change so warm Redis keys cannot serve the old shape after deploy.

14. Zero-Omission API Checklist

  • Every controller route is documented (§4, §8, §13.1).
  • Every parent route prefix and runtime URL is documented (§2, §4).
  • Every DTO field, enum, default, transform and validator is documented (§6, §7).
  • Every response field, nullable field, generated field and omitted raw entity field is documented (§8.1, backend §5.2).
  • Every auth, guard, permission, public decorator and guest identity branch is documented (§5).
  • Every success, validation, auth, permission, not-found, conflict, rate-limit and server-error branch is documented (§8 error tables).
  • Every DB read/write, cache hit/miss/invalidation, queue job, audit log and external call is documented (§11, backend §9/§10/§11).
  • Every route has examples for minimal request, success response and representative failures (§8).
  • Every endpoint family has route, sequence, activity and error diagrams (§9, backend §7).
  • Every tradeoff and compatibility risk is documented (§13.5, §13.6).
  • The API doc links to backend and features/flows (§1, See Also).

15. Integration Checklist

  • Every route from controllers is documented.
  • Every DTO field is documented.
  • Every enum value is documented.
  • Every response envelope is documented.
  • Every error code is documented.
  • Every auth guard and permission is documented.
  • Every cache key, queue job and external call is documented.
  • Every diagram matches the current code.
  • The API doc links to backend and features/flows.

Variants

Since the variant migration, price, SKU and stock live on the variant, not on the product. product.mrp and product.selling_price are trigger-maintained rollups over the product's live, active variants, and inventory, cart_item, checkout_session_item, order_item and pos_sale_item all key on variant_id.

MethodPathPermission
GET/api/admin/products/{productPublicId}/variantsProducts_READ
GET/api/admin/products/{productPublicId}/variants/{publicId}Products_READ
POST/api/admin/products/{productPublicId}/variantsProducts_CREATE
PATCH/api/admin/products/{productPublicId}/variants/{publicId}Products_UPDATE
DELETE/api/admin/products/{productPublicId}/variants/{publicId}Products_DELETE

Permissions reuse Products_* deliberately. A separate Variants_* set would create roles that can edit a product but not its price, which is not a role anybody wants.

Rules

  • Every product has at least one variant, and exactly one is the default. Deleting the last live one is refused.
  • Writing a variant's price moves the product's rollup, because a statement-level trigger recomputes selling_price, mrp, max_selling_price and variant_count from the variants. The admin UI must re-read the product after a variant write rather than assuming its price fields are unchanged.
  • Writes are optimistically locked on version. Send the version you read.
  • A variant carries exactly one value per option axis the product declares, and no two variants of a product may share a combination. Both are enforced by the database.
  • Repricing the PRODUCT row directly is meaningless — it is derived, and the next variant write overwrites it.

Error codes

errorCodeHTTPMeaning
PRODUCT_VARIANT_NOT_FOUND404No such variant on that product
PRODUCT_VARIANT_VERSION_CONFLICT409Someone else saved first; re-read
PRODUCT_VARIANT_CANNOT_DELETE_LAST409A product must keep one live variant
PRODUCT_VARIANT_SKU_ALREADY_EXISTS409
PRODUCT_VARIANT_OPTION_VALUES_INVALID400Missing an axis, or a value from another product
PRODUCT_VARIANT_DUPLICATE_COMBINATION409That option combination already exists
PRODUCT_VARIANT_CANNOT_DEACTIVATE_DEFAULT409Promote another variant first

Media

Each variant carries its own thumbnailKey, thumbnailAlt and ordered gallery[] — never shared with the product's own gallery, though the two use the identical element shape ({key, alt, order}) so the storefront can compose them into one slide strip.

CreateProductVariantDto / UpdateProductVariantDto accept thumbnailKey (storage key, IsStorageKey), thumbnailAlt and gallery[] (ProductVariantGalleryItemDto[], capped at MAX_VARIANT_GALLERY_ITEMS = 20 — lower than the product's 30 because it is read on every product-detail request for every live variant). The gallery editor always sends the field explicitly: omitting gallery leaves the stored gallery untouched; sending [] clears it.

Admin response (ProductVariantResponseDto) carries both the raw key and the resolved URL — thumbnailKey / thumbnailUrl, and each gallery item's key / url — because an operator legitimately previews objects that are not yet public. The customer response never does this; see below.

Customer contractvariants[].media on GET /api/mobile/products/{slug}:

{
  thumbnail: { url: string; alt: string | null } | null;
  gallery: { url: string; alt: string | null; order: number }[];
}

Resolution order (shared with the product-level rule, not a second implementation): the variant's own thumbnailKey, trimmed, when non-empty; else the gallery entry with the lowest order (ties broken by array position); else null. thumbnail: null with an empty gallery is the normal case — "this variant adds no media of its own" — and the storefront falls back to the product's thumbnail, never a placeholder. An unresolvable key is dropped from the gallery and never emitted as { url: "" }, which is truthy and would defeat a caller's if (media.thumbnail) check. See Backend §5.4 for the full rule and why the trim is required.

GET /api/products/related/{slug}

Public, also served under /api/mobile. Cached, cursor-paginated, and returns the same card shape as every other listing.

It reuses ProductStorefrontQueryService rather than running its own query, and that is the whole design: it guarantees the identical published/not-deleted predicate, the identical keyset pagination, the identical card shape and the identical price rollups as GET /products. A hand-rolled query here would be a second place for the storefront's visibility rules to drift.

Products are related by sharing the subject's category or subcategory. The subject itself is excluded.

See Also

On this page

Products - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface Map5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 CreateProductDto (body of POST /api/admin/products)6.2 Facet PATCH DTOs6.3 Bulk DTOs6.4 Storefront query DTOs7. Enum Reference8. Endpoint Reference8.1 GET /api/mobile/productsPurposeSource EvidenceAuth and PermissionsRequestResponseThe pricing block, since variants and special dealsvariants[] — DETAIL ONLYSide EffectsError CasesExample Requests8.2 GET /api/mobile/products/search8.3 GET /api/mobile/products/suggestions8.4 GET /api/mobile/products/:slugPurposeAuth and PermissionsResponseError CasesEdge Cases8.5 GET /api/mobile/products/discovery/:feed8.6 POST /api/admin/productsPurposeAuth and PermissionsRequestResponseSide EffectsError Cases8.7 PATCH /api/admin/products/:publicId8.8 PATCH /api/admin/products/:publicId/lifecycle8.9 PATCH /api/admin/products/:publicId/stockPurposeRequestError Cases8.10 PATCH /:publicId/{pricing,media,seo,discovery,tags}8.11 DELETE /api/admin/products/:publicId8.12 POST /api/admin/products/:publicId/restore8.13 POST /api/admin/products/bulk/*8.14 POST /api/admin/products/jobs/import8.15 POST /api/admin/products/jobs/export8.16 POST /api/admin/products/jobs/:publicId/cancel8.17 GET /api/admin/products/jobs(/:publicId)8.18 GET /api/admin/products/jobs/import-template8.19 Tag endpoints (/api/admin/catalog/tags, /api/mobile/catalog/tags)9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence (admin mutation)9.3 Error Branch (lifecycle)10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist15. Integration ChecklistVariantsRulesError codesMediaRelated productsGET /api/products/related/{slug}See Also