Supercommerce API Docs
Store API

Search Module — Storefront

HTTP surface for the storefront product search — Typesense-backed faceted search and autocomplete suggestions. Mirrors the beautybarn response shape so the catalog/listing screens…

HTTP surface for the storefront product search — Typesense-backed faceted search and autocomplete suggestions. Mirrors the beautybarn response shape so the catalog/listing screens have a near drop-in surface during the re-platform.

Source: api-modules/search/src/controllers/store-product-search.controller.ts.

The admin reindex/backfill surface lives in docs/separated/admin/search.md.

Text searches (non-empty q, first page) are recorded asynchronously for admin search analytics (search-analytics.md); suggestions and filter-only browsing are not. A better-auth session cookie, when present, attributes the event — the endpoints themselves remain fully public.


Conventions

Authentication

EndpointAuth
GET /store/product-searchnone (public)
GET /store/product-search/suggestionsnone (public)

Response envelope

The search and suggestions endpoints embed their own data key so the wire envelope is the standard { data, metadata?, message, statusCode }:

{
  "data": { /* products + facets, or suggestions + products */ },
  "metadata": { /* on /store/product-search only */ },
  "message": "Success",
  "statusCode": 200
}

The metadata uses the named-pagination shape (total / items / perPage / currentPage / lastPage) rather than the offset-based total / limit / offset / hasMore shape — search is page-based.

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR (malformed attributes JSON, bad price coercion)
500INTERNAL_SERVER_ERROR, SEARCH_UPSTREAM_ERROR

Currency

All variant price fields (price, specialPrice, originalPrice, currentPrice, specialPriceActive, priceStart, priceEnd, minPrice/maxPrice query) are integer subunits (paise / cents / eurocents).


Domain types

ProductSearchVariant

type ProductSearchVariant = {
  id: string;
  sku: string | null;
  price: number | null;
  specialPrice: number | null;
  specialPriceStartDate: string | null;     // ISO
  specialPriceEndDate: string | null;       // ISO
  inventoryQuantity: number;               // on-handreservedsafety stock
  isOrderable: boolean;                    // true even at 0 for backorder/untracked
  minQuantityPerCart: number | null;
  maxQuantityPerCart: number | null;
  thumbnail: string | null;
  images: string[];

  /** Computed at response time from the fields above. */
  originalPrice: number | null;             // = price
  currentPrice: number | null;              // = specialPriceActive ?? price
  specialPriceActive: number | null;        // specialPrice if window currently includes now, else null
};

ProductSearchProduct

type ProductSearchProduct = {
  id: string;
  title: string;
  subtitle: string | null;
  description: string | null;
  slug: string;                              // (beautybarn called this "handle")
  thumbnail: string | null;
  images: string[];
  /** min/max of currentPrice across in-stock variants; null when no variant is priced. */
  priceStart: number | null;
  priceEnd: number | null;
  brand: { id: string; slug: string; name: string } | null;
  inStock: boolean;                        // any variant is orderable
  hasActiveSpecial: boolean;
  /** Average star rating across approved, non-spam reviews and the count
   *  backing it. `average` is 0 and `count` is 0 when there are no reviews. */
  rating: { average: number; count: number };
  variants: ProductSearchVariant[];
  /** Present ONLY when the request passes `withReviews=true`. The product's
   *  newest approved, non-spam reviews (up to 10), newest first. Omitted
   *  (key absent) otherwise. */
  reviews?: ProductSearchReview[];
};

ProductSearchReview

Returned only when withReviews=true. A slim, denormalized snapshot embedded in the search index — enough to render a "latest reviews" preview on a product card without a second call to GET /store/products/:id/reviews.

type ProductSearchReview = {
  id: string;
  reviewerName: string | null;   // display name onlycustomer name or admin-entered first+last
  title: string | null;
  content: string;
  stars: number;                 // 1-5
  recommended: boolean | null;
  isVerifiedPurchase: boolean;
  createdAt: string;             // ISO 8601
  images: string[];              // image URLs, ordered
};

Facets

type ProductSearchBrandFacet = {
  id: string;
  slug: string;
  name: string;
  productCount: number;
};

type ProductSearchIngredientFacet = {
  id: string;
  slug: string;
  name: string;
  productCount: number;
};

type ProductSearchAttributeFacet = {
  code: string;
  title: string;
  values: Array<{ value: string; productCount: number }>;
};

type ProductSearchRatingFacet = {
  rating: number;       // 1-5
  productCount: number; // cumulativeproducts with rating.average >= this value
};

Endpoints

Returns paginated products plus four facet blocks (brands, ingredients, attributes, ratings) computed against the same filtered set so client-side facet UI counts stay accurate. ratings is always 5 rows (1-5, descending) with cumulative "N stars & up" counts — matching how minRating filtering already works — rather than exact per-star counts, so selecting "4★" is expected to also include 5★ products.

Query

NameTypeDefaultNotes
qstring?Trimmed, max 200 chars. Full-text query against title + description.
vendorscsv string?Comma-separated vendor ids. Restricts results to those sellers — powers the storefront seller page (pass the id from GET /store/vendors/:slug).
brandscsv string?Comma-separated brand slugs (or ids — depends on the index field).
categoriescsv string?Comma-separated category slugs/ids.
tagscsv string?Comma-separated tag slugs/ids.
ingredientscsv string?Comma-separated ingredient slugs/ids.
skuscsv string?Comma-separated SKUs (variant-level).
attributesJSON-encoded string?{ "<attribute-code>": "value" | string[] }. Malformed JSON or non-string values returns 400.
minPriceint?Subunit lower bound (inclusive).
maxPriceint?Subunit upper bound (inclusive).
minRatingnumber?Inclusive lower bound on the product's average rating (0..5, fractional). Filters rating.average >= minRating.
maxRatingnumber?Inclusive upper bound on the product's average rating (0..5, fractional).
inStock"true" | "false"?Coerced to boolean. When omitted, no filter is applied.
hasActiveSpecial"true" | "false"?Coerced to boolean.
withReviews"true" | "false"?When true, each product carries a reviews[] array of its latest approved reviews (up to 10, newest first). Off by default — the reviews blob is omitted from the response entirely unless requested.
pageint11..1000
limitintDEFAULT_SEARCH_LIMIT1..MAX_SEARCH_LIMIT
sortByenum?relevance (implicit)One of relevance / price-asc / price-desc / new / best-selling / inventory-high / inventory-low / rating-desc / rating-asc. Every sort carries a deterministic tiebreaker, so applying a filter only narrows the list — it never re-orders the products that remain.

Response 200

{
  "data": {
    "products": [
      {
        "id": "01J9...",
        "title": "Hydrating Toner",
        "subtitle": "200ml",
        "description": "...",
        "slug": "hydrating-toner",
        "thumbnail": "https://cdn.example/p/hydrating-toner.jpg",
        "images": ["https://cdn.example/p/hydrating-toner-2.jpg"],
        "priceStart": 49900,
        "priceEnd": 79900,
        "brand": { "id": "01J9...", "slug": "skin-co", "name": "Skin Co" },
        "inStock": true,
        "hasActiveSpecial": false,
        "rating": { "average": 4.5, "count": 12 },
        "variants": [ /* ProductSearchVariant[] */ ]
      }
    ],
    "brands": [ { "id": "01J9...", "slug": "skin-co", "name": "Skin Co", "productCount": 12 } ],
    "ingredients": [ { "id": "01J9...", "slug": "niacinamide", "name": "Niacinamide", "productCount": 8 } ],
    "attributes": [
      { "code": "size", "title": "Size", "values": [ { "value": "200ml", "productCount": 5 } ] }
    ],
    "ratings": [
      { "rating": 5, "productCount": 18 },
      { "rating": 4, "productCount": 30 },
      { "rating": 3, "productCount": 35 },
      { "rating": 2, "productCount": 36 },
      { "rating": 1, "productCount": 36 }
    ]
  },
  "metadata": {
    "total": 42,
    "items": 20,
    "perPage": 20,
    "currentPage": 1,
    "lastPage": 3
  },
  "message": "Success",
  "statusCode": 200
}

GET /store/product-search/suggestions — Autocomplete

Returns distinct title suggestions plus a small set of matching products for an inline search dropdown.

Query

NameTypeDefaultNotes
qstring— requiredTrimmed, 2..100 chars
limitintDEFAULT_SUGGESTIONS_LIMIT1..20

Response 200

{
  "data": {
    "suggestions": ["Hydrating Toner", "Hydrating Serum"],
    "products": [ /* ProductSearchProduct[] */ ]
  },
  "message": "Success",
  "statusCode": 200
}

Errors

StatusCodeWhen
400VALIDATION_ERRORq < 2 chars

  • catalog — products and variants returned here are reindexed from the catalog module's writes.
  • inventoryinStock / isOrderable / inventoryQuantity reflect the inventory module's current view (with a small replication lag — the search index is eventually consistent). inventoryQuantity is the same figure checkout will honour: on-hand minus reserved minus safety stock. Filter and render off isOrderable, not inventoryQuantity > 0 — a backorderable or untracked variant is orderable while reporting zero (untracked variants carry a large sentinel count). inStock is true when any variant of the product is orderable.
  • product-attributeattributes facet values come from the product-attribute module.

On this page