Migrate to Product Search

Ask about this Page
Copy for LLM
View as Markdown
Since the Product Projection Search API is deprecated, we recommend using the Product Search API as its replacement. Both APIs use separate search indexes and can be active in the same Project if it has been created before 1 September 2026. You can therefore migrate incrementally and compare results before you switch traffic.
Use this guide to migrate an existing Product Projection Search implementation to Product Search. For a feature-by-feature comparison of both APIs, see Product Search versus Product Projection Search.
This guide focuses on use cases related to storefront search, especially for the Product Listing Page and search results page. Find tips about implementing Product Detail pages in our learning module Implement product discovery and presentation.
If your use cases require features not covered by Product Search, for example the staged representation of the Products, contact commercetools support to discuss possible migration paths.

The migration consists of the following steps:

  1. Inventory your current usage
  2. Activate the Product Search API
  3. Migrate the query
  4. Migrate full-text search
  5. Migrate fuzzy search
  6. Migrate filters
  7. Migrate facets
  8. Migrate sorting
  9. Migrate pagination
  10. Retrieve full Product data
  11. Verify and roll out
  12. Decommission Product Projection Search

Key differences

FeatureProduct Projection SearchProduct Search
Query interfaceGET or POST with URL-encoded query parametersPOST with a JSON body using the search query language
Response formatFull ProductProjection objectsProduct IDs only, with matching Product Variants on request
Indexed projectionCurrent and stagedCurrent only
Full-text searchFixed set of fields through one text.{language} parameterExplicit per field, with optional boosting
Price selectionDuring the search phase through query parametersDuring data retrieval (see Retrieve full Product data)
Prices considered for filtering and sortingFirst Embedded Price of each Product VariantOne valid Price per scope of the Product Variant (Embedded or Standalone according to the Product's priceMode)
Maximum results per page (limit)500100
Default facet countingProduct VariantsProducts
Facet resultsObject keyed by attribute path or aliasArray of named objects

Five of these changes require design decisions rather than a mechanical rewrite. Read them before you start.

  • The response contains IDs, not Products. You need a second step to retrieve the data you render. See Retrieve full Product data.
  • Full-text search is explicit per field. The text.{language} parameter searched a fixed set of fields with name weighted heavier. You now compose the fields and their weights yourself. See Migrate full-text search.
  • Only the current Product representation is indexed. No equivalent of staged=true exists. For staged data, use the Query ProductProjections endpoint with staged=true.
  • Price selection happens after the search. Use the variants.prices.* fields to filter and sort, and apply price selection during data retrieval.
  • Product Search has no GET method. All requests go to Search Products. If you cache Product Listing Page responses on a CDN keyed by URL, that strategy needs to change.

Inventory your current usage

List every Product Projection Search call in your integration and record the following for each. This list is both your migration checklist and your test matrix.

  • The query parameters used: text.{locale}, fuzzy, fuzzyLevel, filter.query, filter, filter.facets, facet, sort, expand, staged, markMatchingVariants, the price selection parameters, localeProjection, and storeProjection.
  • The fields your storefront reads from each response.
  • Whether the caller needs staged data.
  • Whether the caller relies on facet counts being Product Variant counts. This default changes; see Set the facet counting level.
Then check your Product Types against the indexing limits. Also confirm that every Attribute you filter, facet, or sort on has isSearchable set to true in its AttributeDefinition.

Activate the Product Search API

Product Search is not active by default, and activating it triggers indexing. Activate it early so the index is ready before you start testing.

Use the Merchant Center under Settings > Project settings > Storefront Search, or the Change Product Search Indexing Enabled update action with mode: ProductsSearch.
Activate Product Searchbash
curl -sH "Authorization: Bearer {access_token}" -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "version": {version_number},
    "actions": [
      {
        "action": "changeProductSearchIndexingEnabled",
        "enabled": true,
        "mode": "ProductsSearch"
      }
    ]
  }' https://api.{region}.commercetools.com/{projectKey}
Activating ProductsSearch does not deactivate ProductProjectionsSearch. Both indexes are maintained independently while you migrate.
While indexing is in progress, or if Product Search is inactive, the API returns an ObjectNotFound error.
Both APIs are deactivated automatically after 30 consecutive days without calls. Keep calling Product Projection Search until you have fully switched over, otherwise it deactivates and rejects requests with a SearchDeactivated error. This matters if you keep a fallback path that receives no traffic.
Check your API Client scopes. Product Search requires view_published_products:{projectKey}. If you access it with an external OAuth token, include view_products:{projectKey} explicitly in the token scope instead of relying on implied permissions.

Migrate the query

Move each Product Projection Search parameter to its Product Search equivalent.

Product Projection SearchProduct SearchNotes
filter.queryquery in ProductSearchRequestApplied before facets are calculated. Affects results and facet counts.
filterpostFilter in ProductSearchRequestApplied after facets are calculated. Affects results only.
filter.facetsfilter inside each facet expressionScope changes from global to per-facet. See Rebuild multi-select faceting.
text.{language}fullText expression per fieldSee Migrate full-text search.
fuzzy, fuzzyLevelfuzzy expression with levelPer field instead of global.
facetfacets arraySee Migrate facets.
sortsort arrayStructured objects instead of strings.
limit, offsetlimit, offsetMaximum limit drops from 500 to 100.
markMatchingVariantsmarkMatchingVariantsResponse shape differs. See Read matching variants.
expand-Use the data retrieval step.
staged-Only current Products are indexed.
priceCurrency, priceCountry, priceCustomerGroup, priceCustomerGroupAssignments, priceChannel, priceRecurrencePolicyvariants.prices.* fields for filtering and sortingPrice selection moves to the data retrieval step.
localeProjection-Use localeProjection in the data retrieval step.
storeProjectionstores field for filteringUse storeProjection in the data retrieval step for Store-specific data.
Do not use the productProjectionParameters option on Product Search. It is deprecated and will be removed in a future release.

Choose between and, or, and filter

Product Projection Search combined repeated filter parameters with AND, and comma-separated values within one parameter with OR. Product Search makes this explicit with compound expressions:
  • and: all sub-expressions must match, and all contribute to the relevance score.
  • or: at least one sub-expression must match.
  • not: none of the sub-expressions match.
  • filter: behaves like and, but calculates no relevance score. Use it for anything that should not influence ranking, such as Category and Attribute selections. It performs faster.
A SearchQuery can contain up to 50 simple or compound expressions, and string values are limited to 256 characters. To match several values on the same field, use the values array of a single exact expression rather than several or-combined expressions. This keeps you within the expression limit.
Match multiple values on one fieldjson
{
  "query": {
    "exact": {
      "field": "variants.attributes.color.key",
      "fieldType": "enum",
      "values": ["black", "grey"]
    }
  }
}

Respect field-level validation

Product Search organizes searchable fields into three levels and validates compound expressions against them. Product Projection Search had no such rule, so a query that worked before can be rejected with a 400 InvalidInput error.
LevelSearchable Product fieldsRank
Contextstores, productSelections1
Productall Product and Product Variant fields except prices2
Pricevariants.prices.*3

Keep each compound expression on a single level, and combine levels at the top of the query, ordered from context to price.

Query combining context, product, and price criteriajson
{
  "query": {
    "and": [
      { "exact": { "field": "stores", "value": "{{store-id}}" } },
      {
        "filter": [
          { "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } },
          { "exact": { "field": "variants.attributes.color", "fieldType": "text", "value": "red" } }
        ]
      },
      {
        "and": [
          { "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } },
          { "range": { "field": "variants.prices.currentCentAmount", "gte": 1000, "lte": 5000 } }
        ]
      }
    ]
  }
}
If you need to nest mixed levels more deeply, see validation logic details. Two rules apply. The rank of a multi-level expression must be higher than or equal to the rank of any single-level expression it is combined with. Two multi-level expressions cannot be combined.

Migrate filters

Filter expression strings become typed expressions, and several field paths change. Check every path against searchable Product fields.

Map the field paths

Product Projection SearchProduct Search
categories.idcategories
categories.id: subtree("{id}")categoriesSubTree
keykey
productType.idproductType
taxCategory.idtaxCategory
state.idstate
variants.skuvariants.sku
variants.keyvariants.key
variants.price.centAmountvariants.prices.centAmount
variants.scopedPrice.value.centAmountvariants.prices.centAmount
variants.scopedPrice.currentValue.centAmountvariants.prices.currentCentAmount
variants.scopedPriceDiscountedvariants.prices.discounted
variants.attributes.{name}variants.attributes.{name}, with fieldType
variants.availability.isOnStockvariants.availability.isOnStock
variants.availability.channels.{id}.isOnStockvariants.availability.isOnStockForChannel
variants.availability.availableQuantityvariants.availability.availableQuantity
searchKeywords.{language}.textsearchKeywords, with language
reviewRatingStatistics.*reviewRatingStatistics.*
createdAt, lastModifiedAtcreatedAt, lastModifiedAt
-attributes.{name} for Product-level Attributes BETA
-stores, variants.stores
-productSelections, variants.productSelections
-variants.prices.currencyCode, .country, .customerGroup, .channel, .id
Reference-type Attributes keep the .id suffix, enum Attributes keep .key, and Money Attributes keep .centAmount or .currencyCode, as before.

Map the filter operators

Product Projection SearchProduct Search
field:"{value}"exact with value
field:"{value-1}","{value-2}"exact with values
field:range ({from} to {to})range with gte and lte
field:range (* to {to})range with lte only
field:existsexists
field:missingnot wrapping exists
field:trueexact with a boolean value
Repeated filter parametersand or filter compound expression

Three behavior changes to plan for:

  • Range bounds are now explicit. The :range ({from} to {to}) filter included both bounds. Use gte and lte to keep that behavior; gt and lt exclude the bound. Note that range facet buckets include the lower bound and exclude the upper bound in both APIs.
  • Boolean Attribute values are no longer quoted. Custom Boolean Attributes required variants.attributes.{name}:"true". Pass a real boolean with "fieldType": "boolean" instead.
  • not combined with exists is not supported on the variants, variants.prices, and variants.sku fields; such a query always returns an empty result. If you filter on variants.prices:missing or variants.sku:missing, you need a different approach, such as maintaining a Boolean Attribute for the condition. For details, see evaluation of 'not' expressions.

Example: filter by Category and Attribute

Product Projection Searchtext
GET /{projectKey}/product-projections/search
  ?filter.query=categories.id:subtree("{{category-id}}")
  &filter=variants.attributes.color.key:"black","grey"
  &filter=variants.availability.isOnStock:true
Product Searchjson
{
  "query": {
    "filter": [
      { "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } }
    ]
  },
  "postFilter": {
    "filter": [
      { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "values": ["black", "grey"] } },
      { "exact": { "field": "variants.availability.isOnStock", "value": true } }
    ]
  }
}

Example: filter by Price

Prices are where results are most likely to change. Product Projection Search filtered on the first Embedded Price of each Product Variant and did not support Standalone Prices at all. Product Search indexes one valid Price per scope of a Product Variant (Embedded or Standalone according to the Product's priceMode). The same intent can therefore match more Products. Verify affected Product Listing Pages explicitly.
Product Projection Searchtext
GET /{projectKey}/product-projections/search
  ?priceCurrency=EUR&priceCountry=DE
  &filter=variants.scopedPrice.currentValue.centAmount:range (1000 to 5000)
Product Searchjson
{
  "query": {
    "and": [
      { "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } },
      { "exact": { "field": "variants.prices.country", "value": "DE" } },
      { "range": { "field": "variants.prices.currentCentAmount", "gte": 1000, "lte": 5000 } }
    ]
  }
}
Use variants.prices.currentCentAmount when discounted Prices should be taken into account, and variants.prices.centAmount when only the original Prices should count.

Migrate facets

Facet expressions move from strings in repeated facet parameters to objects in the facets array.
Product Projection SearchProduct Search
Term facet, such as variants.attributes.color.keydistinct facet
Range facet, such as variants.price.centAmount:range (...)ranges facet
Filtered facet, such as variants.attributes.color.key:"red"distinct facet with includes, or a count facet with a filter
counting products extensionlevel: "products", which is the default
as {alias}name
filter.facets parameterfilter inside the facet expression
categories.id: subtree("{id}") facetdistinct facet on categoriesSubTree
-stats facets
-missing for a bucket of Products without a value
-scope: "all" for facets that ignore the query
-sort and limit on buckets
Unlike in Product Projection Search, subtree keys and plain Category keys can be combined in one facet through includes.

Set the facet counting level

This is the most easily missed change in the migration. Product Projection Search counted Product Variants by default and counted Products only when you added the counting products extension. Product Search counts Products by default and counts Product Variants when you set level to variants.
If your storefront displays facet counts, decide per facet which entity the number represents and set level explicitly rather than relying on the default. Otherwise the numbers next to your filter options change silently.

Rebuild multi-select faceting

In Product Projection Search, filter.facets was a single global parameter: it narrowed every facet's aggregation except the facet it applied to. In Product Search, each facet expression carries its own filter. To reproduce multi-select faceting on a Product Listing Page:
  1. Put non-facet criteria, such as the Category and the search text, in query. Both the results and every facet inherit them.
  2. Put the union of all selected facet values in postFilter. This narrows the results only.
  3. For each facet, set its filter to the selections of all other facets, excluding its own.
The following example has a color facet and a size facet, with black and m selected. The size facet is filtered by color, and the color facet is filtered by size.
Multi-select faceting on a Product Listing Pagejson
{
  "query": {
    "filter": [
      { "exact": { "field": "categoriesSubTree", "value": "{{category-id}}" } }
    ]
  },
  "postFilter": {
    "filter": [
      { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "value": "black" } },
      { "exact": { "field": "variants.attributes.size.key", "fieldType": "enum", "value": "m" } }
    ]
  },
  "facets": [
    {
      "distinct": {
        "name": "color",
        "field": "variants.attributes.color.key",
        "fieldType": "enum",
        "level": "products",
        "limit": 50,
        "filter": { "exact": { "field": "variants.attributes.size.key", "fieldType": "enum", "value": "m" } }
      }
    },
    {
      "distinct": {
        "name": "size",
        "field": "variants.attributes.size.key",
        "fieldType": "enum",
        "level": "products",
        "limit": 50,
        "filter": { "exact": { "field": "variants.attributes.color.key", "fieldType": "enum", "value": "black" } }
      }
    }
  ],
  "limit": 20,
  "offset": 0
}

Read the facet results

Product Search returns facets as an array of objects, each identified by the name you assigned. Product Projection Search returned an object keyed by attribute path or alias.
ProductPagedSearchResponsejson
{
  "total": 148,
  "offset": 0,
  "limit": 20,
  "facets": [
    { "name": "color", "buckets": [{ "key": "black", "count": 37 }] },
    { "name": "size", "buckets": [{ "key": "m", "count": 42 }] }
  ],
  "results": [{ "id": "8fde2af0-6a2f-4633-9ba4-83566f769a7f" }]
}
Rework any client code that reads facets by attribute path, such as facets["variants.attributes.size"].
The dataType, total, other, and missing fields of the Product Projection Search term facet result do not exist in Product Search. Use the missing option to get a bucket for Products without a value. Use limit (up to 200) to control how many buckets are returned.

Migrate sorting

Sort strings become objects, and the sort mode moves out of the sort direction.

Product Projection SearchProduct Search
name.en asc{ "field": "name", "language": "en", "order": "asc" }
price desc{ "field": "variants.prices.centAmount", "order": "desc", "mode": "max" }
variants.scopedPrice.currentValue.centAmount asc{ "field": "variants.prices.currentCentAmount", "order": "asc", "mode": "min", "filter": { ... } }
variants.attributes.color.label.en asc.max{ "field": "variants.attributes.color.label", "language": "en", "fieldType": "lenum", "order": "asc", "mode": "max" }
categoryOrderHints.{id} asc{ "field": "categoryOrderHints.{id}", "order": "asc" }
score desc{ "field": "score", "order": "desc" }
createdAt, lastModifiedAt, idunchanged as field values
Translate the compound direction suffixes as follows: asc.min becomes order: "asc" with mode: "min", desc.max becomes order: "desc" with mode: "max", and so on. Product Search adds the avg and sum sort modes.
Two behaviors carry over unchanged: Products with no value for the sort field are placed last, and an unstable sort produces non-deterministic paging. End every sort array with a field that has distinct values across all Products, usually id.
Deterministic sorting with a tie-breakerjson
{
  "sort": [
    { "field": "name", "language": "en", "order": "asc" },
    { "field": "id", "order": "asc" }
  ]
}

Replace scoped price sorting

Sorting by variants.scopedPrice.* relied on the price selection parameters of the search request. In Product Search, express the scope in the sort's own sort filter.
Sort by Price scoped to a Channel and currencyjson
{
  "sort": [
    {
      "field": "variants.prices.centAmount",
      "order": "asc",
      "mode": "min",
      "filter": {
        "and": [
          { "exact": { "field": "variants.prices.currencyCode", "value": "EUR" } },
          { "exact": { "field": "variants.prices.channel", "value": "{{channel-id}}" } }
        ]
      }
    }
  ]
}
Sorting runs independently of query and postFilter. The sort filter must therefore repeat any scoping the ranking depends on. A sort filter must also stay on the same field level as the sort field. A price sort therefore cannot be scoped by a product-level field such as an Attribute value. If your current implementation sorts by scoped price while filtering by an Attribute, the ranking scope becomes wider than before. Products whose non-matching Variants carry lower Prices can then appear earlier than expected. This is a known constraint, not a paging problem.

Migrate pagination

The limit and offset fields keep their names and meaning, and you can still retrieve the first 10 000 results. Three changes apply:
  • The maximum limit drops from 500 to 100. If you request pages of more than 100 results, reduce the page size or fetch several pages.
  • The maximum usable offset depends on the limit, because the 10 000-result ceiling still applies. With a limit of 100 the maximum offset is 9 900; with the default limit of 20 it is 9 980.
  • Exceeding the ceiling returns an InvalidInput error instead of a SearchExecutionFailure error. Update any error handling that matches on the old error code.
Setting limit to 0 to retrieve facets only works in both APIs.

Retrieve full Product data

Product Search returns Product IDs. Choose one of the following approaches to retrieve the data you render. This is also where price selection, locale projection, and Store projection now happen.

With GraphQL

Use the product field inside the productsSearch query to fetch Product data and apply price selection in the same request. For details, see use Product Search with GraphQL.
Fetch Product data for matching Productsgraphql
{
  productsSearch(
    query: { fullText: { field: "name", value: "skirt", language: "en" } }
    limit: 20
  ) {
    total
    offset
    limit
    results {
      product {
        masterData {
          current {
            name(locale: "en")
            slug(locale: "en")
            masterVariant {
              sku
              images { url }
              price(currency: "EUR", country: "DE") {
                value { centAmount }
              }
              attributesRaw(includeNames: "designer") { name value }
            }
          }
        }
      }
    }
  }
}

This is the closest equivalent to the single-call model of Product Projection Search and usually the smallest change to a storefront.

With the Product Projections API

Pass the returned IDs to the Query ProductProjections endpoint in a single request.
Query ProductProjections for the matching IDstext
GET /{projectKey}/product-projections
  ?where=id in ("{{id-1}}","{{id-2}}","{{id-3}}")
  &staged=false
  &priceCurrency=EUR&priceCountry=DE&priceCustomerGroup={{customer-group-id}}
  &localeProjection=en
  &storeProjection={{store-key}}
Every price selection, locale, and Store parameter that you previously passed to Product Projection Search is available on this endpoint. This is where the projection behavior of your current implementation is preserved. Use filter[attributes] to reduce the response to the Attributes you render.
The returned Product Projections are not guaranteed to be in the same order as the Product IDs from the search response. Either apply the same sort criteria on this request, or reorder the results client-side against the ID list. Reordering client-side is the more reliable of the two.
You can also fetch each Product Projection individually with Get ProductProjection by ID. This issues one request per result and is not recommended for Product Listing Pages.

To improve performance, add a caching layer to persist frequently requested product data between sessions.

Read matching variants

The markMatchingVariants field exists in both APIs, but the response differs. Product Projection Search added an isMatchingVariant boolean to each returned Product Variant. Product Search returns a separate ProductSearchMatchingVariants object per result.
Result with matching variantsjson
{
  "id": "{{product-id}}",
  "matchingVariants": {
    "allMatched": false,
    "matchedVariants": [
      { "id": 1, "sku": "CSKW-093" },
      { "id": 5, "sku": "CSKP-0932" }
    ]
  }
}
When allMatched is true, all Variants match and matchedVariants is empty. Handle that case explicitly; it is a common cause of empty variant lists after migration.

New capabilities

Product Search offers capabilities that Product Projection Search does not. Consider whether they let you simplify your implementation, or remove workarounds you built for their absence.

  • Prefix matching: match the beginning of a field value with prefix expressions.
  • Wildcard matching: match patterns within field values with wildcard expressions.
  • Field-level boosting: control relevance scoring per field with boosting.
  • Product-level Attributes: search by Attributes defined at the Product level with the attributes.{name} field, not only at the Variant level. You can remove SameForAll workarounds built for Product Projection Search.
  • Standalone Prices: filter, sort, and facet on Standalone Prices.
  • Stores and Product Selections: filter on the stores and productSelections fields.
  • Stats facets: calculate minimum, maximum, mean, sum, and count for number and date fields with stats facets.

Indexing limits and validation

Product Search enforces the following indexing limits per Product Variant:
  • Up to 50 Variant Attributes.
  • Up to 50 Product Attributes.
The limits apply separately, so a Product Variant can have both. Attributes are counted in the order they are defined on the ProductType. The indexer skips any Attribute that is not present on the Product Variant or not marked as searchable. Once the limit is reached, the remaining Attributes are not indexed.
If a Product Variant carries more searchable Attributes than the limit, you have two options. Reorder the AttributeDefinitions on the Product Type. Alternatively, set isSearchable to false on Attributes you never query. Either way, make sure the Attributes you do query fall within the limit.
Product Search also applies the field-level validation described in Respect field-level validation. Compound expressions that combine fields from different levels incorrectly are rejected with an InvalidInput error.

The following soft limits also apply to the search index:

  • 15 000 Stores per Product.
  • 15 000 Product Selections per Product.
  • 10 000 Standalone Prices per Product.

Exceeding a soft limit produces non-deterministic results for expressions on the affected fields.

Verify and roll out

Migrate one entry point at a time. Product Listing Pages, search result pages, Category pages, and internal tools have different requirements. Running both APIs in parallel lets you compare them safely.

  1. Run both APIs in shadow mode. For a sample of live traffic, issue the old and the new request and log both result sets. Do not change what customers see yet.
  2. Compare result sets, not just counts. Check total, the ID sets, their order, and the facet counts. Account for the indexing delay. Both indexes are eventually consistent and update independently. Small differences in the minutes after a catalog change are expected, so compare against a stable catalog.
  3. Expect deliberate differences. Price filters now consider all Prices. Facet counts changed from Variants to Products. Relevance ranking depends on your new full-text composition. Confirm that each difference is one you intended.
  4. Tune relevance. Adjust boost values, mustMatch, and the set of searched fields against real queries from your search logs before you switch traffic.
  5. Roll out behind a feature toggle. Route a small share of traffic to Product Search, monitor response times and conversion, then increase the share. For the broader pattern, see Migrate checkout.
  6. Keep the fallback alive. Keep the Product Projection Search path working until Product Search carries all traffic. Keep calling it as well, so its index is not deactivated automatically.

Troubleshooting

SymptomLikely cause
ObjectNotFound: Product Search API is not enabledProduct Search is not activated, or the initial indexing has not finished.
400 InvalidInput with "Expressions nesting level are incompatible"A compound expression combines field levels incorrectly. See Respect field-level validation.
400 MalformedQueryAn or expression with a single sub-expression, or a fieldType that does not match the Attribute's type.
Empty results for an Attribute queryisSearchable is false, the Attribute is beyond the indexing limit, or the fieldType is wrong.
Empty results for a localized queryThe Locale is not configured in the Project's languages.
Empty results from not combined with existsNot supported on variants, variants.prices, and variants.sku.
Facet counts changed after migrationThe default counting level changed from Product Variants to Products. Set level explicitly.
More Products match a price filter than beforeProduct Search indexes all Prices, not only the first Embedded Price.
matchedVariants is emptyallMatched is true, meaning all Variants match.
Products appear in an unexpected order across pagesThe sort array has no tie-breaker, or a sort filter does not match the query.
403 ForbiddenThe token lacks view_published_products, or an external OAuth token does not list view_products explicitly.