Search items

post/v1/search

Runs a semantic search over your catalogue and returns the best-matching items, ranked by a blend of vector similarity, metadata agreement with your filters, price fit and (optionally) colour distance and keyword boosts.

How a search runs

  1. Negations in the query are read out of it: "dress not black", "sweater without wool", "jacket, no hood". The negated words are added to exclude_terms and removed from the text that is embedded, because an embedding cannot negate — "not black" would otherwise pull black items forward. The response's parsedQuery shows what was read. A negation of degree ("not too tight") is left alone.
  2. The query is embedded and matched against the vector index. Only items that pass the hard filters take part: the datasets being searched, gender, slot, family and age_group. Up to 150 candidates come back from this stage.
  3. The candidates are priced in the shopper's market when country is sent: price, currency, each variant's price, sale price and stock, and onSale / discount. Products that market does not sell are dropped. Without country they keep the store's default prices, flagged product.marketExact: false (see Prices in the shopper's market below).
  4. exclude_terms, filters.onSale, filters.inStock and, when strictPriceFilter is true, the price range are applied as hard filters on those candidates, against the prices from step 3.
  5. Candidates less than 65% as similar to the query as the best remaining candidate are dropped (the relevance floor), and the rest are scored (see Scoring below). The top limit are returned, so a search can return fewer than limit items when only a few are close to the query. Each result carries its full product record plus a scoring breakdown so you can see why it ranked where it did.

Prices in the shopper's market

A Shopify store can sell in several markets at prices the merchant sets for each (not converted), and some products are not sold in every market. Send the shopper's country (two letters, such as US) and every result is priced as that shopper would pay: price, currency and each variant's price, compareAtPrice and inStock are their market's, and product.market names it. The price band, price scoring and filters.onSale then use those prices too, so minPrice and maxPrice are in the shopper's currency. Products their market does not sell are left out.

On a Shopify storefront the shopper's country is Shopify.country on the page. Without it, or for a country the store does not sell into, results come back at the store's default market's prices with product.marketExact: false and the reason in product.marketFallback, so you can label the prices rather than present them as the shopper's. product.referencePrice and referenceCurrency always carry the stored default-market price.

How relevant the results are

Every response carries relevance: the best candidate's raw cosine similarity (top), how many candidates there were and how many cleared the floor, and weak. weak is true when top is under 0.2, which is how a query for something the catalogue does not stock tends to look ("sunglasses" in a clothing store scores about 0.14). It is a hint, not a verdict: very loose requests ("something for a beach holiday") can sit near the same level, so use it to word the results as "the closest we have" rather than to hide them. Nothing is dropped because of it.

Which datasets are searched

  • Omit datasetIds (or send []) to search every active dataset in your organization. If none is active the request fails with 404.
  • Pass datasetIds to search specific datasets. Draft datasets are searched but produce a warning; archived datasets are skipped with a warning; an id that does not belong to your organization fails the request with 422.

Hard filters vs. ranking hints

gender, slot, family, age_group, exclude_terms, onSale, inStock and the strict price range remove items. Everything else in filters — color_family, fit, pattern, material, features, color_rgb, boost_terms and a non-strict price range — only re-orders the results. An item that misses a soft filter is pushed down, not dropped, so a search never comes back empty just because one preference could not be met. A preference reorders items that are relevant to the query; it cannot bring back one the relevance floor dropped.

Every filter that takes a string also accepts an array of strings, except gender, which takes exactly one value. A single value is matched exactly; an array matches any of its values. Values are compared case-insensitively after trimming.

slot, family and age_group are closed vocabularies from the catalogue taxonomy, and so is every entry of features. A value outside them could only ever match nothing, so it is refused with 422 and the error lists what is allowed. The soft filters are not checked: an unknown colour or fit simply boosts nothing.

Send gender on shopper-facing searches. Without it every department is searched.

Scoring

Everything is on one scale: relevance to the query. vector is the item's similarity divided by the best candidate's, so the best match is 1 and one a fifth less similar is 0.8. Each other component is bounded and added with a weight that says how much relevance it is worth:

final = vector + 0.2 × metadata + 0.15 × price + 0.2 × rgbColor + 0.15 × keywordBoost

component weight range what it measures
vector 1 0.65–1 Cosine similarity to the query relative to the best candidate. Candidates under 0.65 are not returned.
metadata 0.2 −1–1 Agreement with the soft filters. Per field, a match adds and a miss subtracts: color_family 1.5 / 1.5, pattern 0.5 / 0.3, fit 0.4 / 0.2, material 0.3 / 0, features 0.5 / 0 per matching feature, capped at 1.5 (match / miss). Summed and normalised by the match weights of the filters supplied. A field the item has no value for counts neither way. 0 when no soft filters are sent.
price 0.15 −1–0 0 inside [minPrice, maxPrice]; outside it a penalty that grows with the relative distance from the nearer bound, −(1 − e^(−distance × pricePenalty / 0.35)): about −0.25 at 10% outside, −0.76 at 50%. 0 when no price range is sent or the item has no price.
rgbColor 0.2 −1–1 Closeness of item.metadata.color_rgb to color_rgb, as colour difference in CIE Lab (ΔE): 1 for the same colour, 0 at ΔE 30 (roughly white against light blue), −1 at ΔE 60 and beyond (light blue against black). 0 when the item has no measured colour. Only present when color_rgb is sent.
keywordBoost 0.15 0–1 Fraction of boost_terms found (case-insensitive substring) in the product name or description. Only present when boost_terms is sent.

Being inside the price band earns nothing, so an item is never ranked above a more relevant one for its price alone.

Attaching results to a conversation

Pass the chatId of a chat you own and the slotId of a search slot in that chat's most recent assistant message, and the results are written into that slot so the stylist can refer to them later. Problems with the slot never fail the search — they are reported in warnings and the results are returned regardless. A slotId without a chatId is ignored with a warning; an unknown or inaccessible chatId does fail the request.

Warnings

warnings is only present when there is something to say. Slot-related warnings are plain strings; dataset warnings are objects with a message field.

Public keyapi.v1.search.queryDemo key OK

Request body

application/jsonrequired
querystringrequired

Natural-language description of what to find. Trimmed before use. "not X", "without X" and "no X" are read as exclusions (see How a search runs).

minLength: 1maxLength: 512e.g. "relaxed linen shirt for a summer wedding"
datasetIdsarray<string>

Datasets to search. Omit or send [] to search every active dataset in your organization. Draft datasets are searched with a warning; archived datasets are skipped with a warning; ids from another organization fail the request.

default: []
SearchFilters

Narrow and re-rank the results. Fields marked Hard. remove items; everything else only changes ordering. Any string-valued field except gender also accepts an array of strings (match any). Unknown fields are ignored.

genderstring

Hard. One of mens, womens or unisex, the same values items carry in metadata.gender. mens or womens searches that gender's items plus those tagged unisex; unisex searches unisex items only; omitting the field searches every department. A single string only: do not send an array.

One ofmenswomensunisex
e.g. "womens"
string | array<string>

Hard. Where the item is worn, or what kind of thing it is, matched against metadata.slot. A string matches that slot; an array matches any listed slot. An unknown value returns 422.

e.g. "outerwear"
string | array<string>

Hard. What kind of thing the item is within its slot, matched against metadata.family: a taxonomy family name such as dress, pant, t shirt, sneaker or blazer. Families are shared by every catalogue, so jeans are pant and a tee is t shirt; put the finer detail ("jeans", "chelsea") in query or boost_terms. A string matches that family; an array matches any listed family. An unknown family returns 422 listing the allowed names.

string | array<string>

Hard. adult, kids or baby, matched against metadata.age_group. adult means "not kids and not baby", so items processed before the field existed still match it. kids and baby match only items labelled as such. An unknown value returns 422.

e.g. "adult"
string | array<string>

Ranking hint. Colour family of the item (navy, white, olive, …). Strongest metadata signal (1.5 added on a match, 1.5 subtracted on a miss).

color_rgbarray<integer>

Ranking hint. Target colour as [r, g, b] integers in 0–255. Adds an rgbColor component to every result's scoring from the colour difference (CIE Lab ΔE) to the item's own metadata.color_rgb. Validated strictly; send null or omit to disable.

minItems: 3maxItems: 3
string | array<string>

Ranking hint. One of skinny, slim, regular, relaxed, oversized or other (0.4 match / 0.2 miss). Items with no fit (footwear, bags) are neither rewarded nor penalised.

string | array<string>

Ranking hint. Pattern such as solid, striped, checked, floral (0.5 match / 0.3 miss). See DatasetItemMetadata.pattern for the full list.

e.g. "solid"
string | array<string>

Ranking hint. Fibre such as linen, cotton, wool (see DatasetItemMetadata.material for the full list). Items may list several fibres; each match adds 0.3, with no penalty for a miss.

string | array<string>

Ranking hint. Taxonomy features as axis:value strings, matched against metadata.featureKeys (for example neckline:v neck, sleeve:long, dress length:midi, leg cut:wide leg). Each match adds 0.5, capped at 1.5 in total; an item is never penalised or excluded for lacking one. A string that is not a taxonomy axis:value returns 422.

exclude_termsarray<string>

Hard. Items whose product name or metadata description mentions any of these terms are removed. Terms match whole words, case-insensitively, with plurals and participles folded: hood removes "hooded", stripes removes "striped" and "stripe", sleeve removes "long sleeve" but not "sleeveless", black does not remove "blackwatch". A term of several words matches them in order. Must be an array; a single string is ignored. Negations read from query are added to this list (see parsedQuery in the response).

boost_termsarray<string>

Ranking hint. Terms to look for in the product name and metadata description (case-insensitive substring). Adds a keywordBoost component equal to the fraction of terms found. Must be an array; a single string is ignored.

inStockboolean

Hard. true keeps only products the shopper can buy: sold in the market results are priced in (see country), with at least one variant in stock there. A product whose source lists no variants is kept. false or omitted applies no filter. The Stylor agent always sends true.

e.g. true
onSaleboolean

Hard. true keeps only products on sale for this shopper: an in-stock variant has a compareAtPrice above its price in the market results are priced in (see country). false or omitted applies no filter.

e.g. true
minPricenumber

Lower bound of the preferred price band, in the currency results are priced in — the shopper's market's when country is sent, otherwise the store's default. Enables price scoring when sent alone or with maxPrice.

e.g. 60
maxPricenumber

Upper bound of the preferred price band, in the same currency as minPrice. Enables price scoring when sent alone or with minPrice.

e.g. 140
pricePenaltynumber

How quickly the price penalty grows outside the band; 2 reaches a given penalty at half the distance. Must be positive; anything else uses the default. Only read when minPrice or maxPrice is set. The penalty stays within −1 whatever this is.

default: 1e.g. 1.5
strictPriceFilterboolean

When true and a price band is set, items outside the band — and items with no price — are removed instead of penalised.

default: falsee.g. true
countrystringnullable

The shopper's country, ISO 3166-1 alpha-2 in any case (US, ca). Results are priced in that shopper's market, the price band, price scoring and filters.onSale use those prices, and products their market does not sell are left out. On a Shopify storefront, read it from Shopify.country. Omitted, results carry the store's default market's prices, flagged product.marketExact: false. Anything but two letters returns 422.

pattern: ^[A-Za-z]{2}$e.g. "US"
limitinteger

Most results to return. Must be 1–100; out-of-range values are rejected, not clamped. Fewer come back when fewer candidates clear the relevance floor (see How a search runs).

default: 5min: 1max: 100e.g. 10
chatIdstringnullable

Id of a chat owned by your organization. When sent, the chat must exist; combine with slotId to store the results in the conversation.

e.g. "8b4d3f6e-2c1a-4f0e-9d7b-5a6c1e2f3a4b"
slotIdstringnullable

Id of a search slot in the chat's most recent assistant message. On success the top results are written into that slot as { itemId, description, displayOrder } entries. Ignored (with a warning) when chatId is absent or the slot cannot be found.

e.g. "3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9"

Response

200Ranked results, plus any non-fatal warnings.
array<SearchResultItem>required

Up to limit items, best match first. Fewer when fewer candidates clear the relevance floor; empty when nothing passed the hard filters.

itemIdstring (uuid)required

Unique item id. It is the same id the queue entry was given at upload time.

e.g. "8b0a1b62-7c2a-4b7f-9e2c-0f3a9b1f0c11"
datasetIdstring (uuid)required

The dataset the item belongs to.

e.g. "c9b3c6c0-9b0f-4f42-bf3f-2b7a6b1b9d31"
organizationIdstring (uuid)required

The organization that owns the dataset.

e.g. "0a2c1f4e-5b6d-7e8f-9012-3456789abcde"
statusstringrequired

Only active items are listed and searchable.

One ofactivearchived
e.g. "active"
versionnumberrequired

Ingest pipeline version the item was processed with.

e.g. 1
DatasetItemMetadatarequired

What Stylor's product labeller read from the item's photos and listing when it was processed. Fields with an enum are closed lists; the taxonomy (slot, family, features) is shared by every catalogue. Items processed before a field existed may lack it.

DatasetItemProductrequired

Merchant-facing product data. Everything here comes from your upload or your dataset configuration, not from AI.

DatasetItemOriginrequired

Where the item came from. Copied from the dataset's store configuration at processing time.

createdAtstring (date-time)required

When the processed item was first written.

e.g. "2025-03-04T18:22:41.913Z"
updatedAtstring (date-time)required

When the item was last rewritten by a sync or reprocess.

e.g. "2025-03-04T18:22:41.913Z"
scorenumberrequired

Raw cosine similarity from the vector index, before normalisation. Comparable across searches, unlike scoring.vector.

e.g. 0.4812
SearchScoringrequired

Per-result breakdown of the ranking formula. Components that were not active for this request are omitted.

vecNormnumberrequired

Legacy alias of scoring.vector.

e.g. 0.92
metaBonusnumberrequired

Legacy alias of scoring.metadata.

e.g. 0.67
blendedScorenumberrequired

Legacy alias of scoring.final.

e.g. 0.74
SearchRelevancerequired

How well the best candidate matched the query. A signal for how to present the results; nothing is dropped because of it.

topnumbernullablerequired

The best candidate's raw cosine similarity, after the hard filters. null when there were no candidates.

e.g. 0.4812
weakbooleanrequired

true when top is under 0.2 (or null): the catalogue may not carry what was asked for, and the results are only the nearest items. Very loose requests can also land here.

e.g. false
candidatesintegerrequired

Candidates that passed the hard filters.

e.g. 142
aboveFloorintegerrequired

Candidates that cleared the relevance floor; results holds up to limit of them.

e.g. 9
object

Only present when negations were read out of query. What was searched for, and the terms added to exclude_terms.

querystringrequired

The text that was embedded, with the negated phrases removed. The original query when nothing else would have been left.

e.g. "dress"
exclude_termsarray<string>required

Terms read from the query and excluded, in addition to any sent in filters.exclude_terms.

array<string | object>

Only present when at least one warning was raised.

Errors

40

Every error is JSON with a single error string unless noted. Match on the status code; the message is for humans.

400
Request body must be valid JSON.

The request body could not be parsed as JSON.

400
Request body must be a JSON object.

The request body is empty, or parsed to something other than a JSON object (for example an array or a string).

404
Chat not found or you do not have permission to access it.

chatId was sent but no chat with that id belongs to your organization.

404
No active datasets found for this organization. Activate one via the dashboard (Dataset → Settings) or through the REST API before proceeding.

datasetIds was omitted or empty and none of your datasets is active.

404
All requested datasets are archived. Reactivate at least one via the dashboard or REST API.

Every dataset in datasetIds is archived.

404
No datasets found for this organization. Create one via the dashboard or REST API, or pass datasetIds to use draft/archived datasets before proceeding.

Your organization has no datasets at all.

422
chatId must be a non-empty string.

chatId was sent but is not a string or is only whitespace.

422
The 'query' field must be a non-empty string.

query is missing, not a string, or blank after trimming.

422
The 'query' string must be shorter than 512 characters.

query is longer than 512 characters after trimming (exactly 512 is accepted).

422
The 'limit' must be a number.

limit was sent but is not a finite number (for example a string or null).

422
Limit must be between 1 and 100.

limit is below 1 or above 100. The value is not clamped.

422
datasetIds must be an array.

datasetIds is present but not an array (including null).

422
filters must be an object.

filters is present but is not an object (including null or an array).

422
country must be a two-letter ISO 3166 country code, such as 'US'.

country was sent (non-null) but is not a string of exactly two letters. Case does not matter.

422
filters.onSale must be a boolean.

filters.onSale was sent (non-null) but is not true or false.

422
filters.inStock must be a boolean.

filters.inStock was sent (non-null) but is not true or false.

422
Unknown slot ["<value>", …]. Allowed: accessory, bag, jewellery, …

A value in filters.slot is not a taxonomy slot. The same message names family or age_group when one of those holds an unknown value, and lists every allowed value.

422
Unknown features ["<value>", …]. Each is "axis:value" from the taxonomy, e.g. "neckline:v neck".

An entry of filters.features is not a taxonomy axis:value string.

422
Invalid color_rgb: RGB must be an array

filters.color_rgb was sent (non-null) but is not an array.

422
Invalid color_rgb: RGB must have exactly 3 values [r, g, b]

filters.color_rgb does not have exactly three entries.

422
Invalid color_rgb: Each RGB value must be an integer between 0 and 255

An entry of filters.color_rgb is not an integer in 0–255.

422
Invalid datasetIds for this organization: <id, id, …>

One or more values in datasetIds do not belong to your organization. The offending ids are listed, comma-separated.

502
Failed to generate embedding for the provided query.

The embedding provider failed to embed the query. Safe to retry.

curl -X POST "https://stylor.ai/api/v1/search" \
  -H "Authorization: Bearer $STYLOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "query": "relaxed linen shirt for a summer wedding",
  "filters": {
    "gender": "mens",
    "family": "shirt",
    "color_family": [
      "white",
      "beige"
    ]
  },
  "limit": 10
}'
Response · 200
{
  "results": [
    {
      "itemId": "9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f",
      "organizationId": "0b7c3d9e-1f2a-4b3c-9d8e-7f6a5b4c3d2e",
      "datasetId": "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "status": "active",
      "version": 4,
      "product": {
        "name": "Linen Camp Collar Shirt",
        "sku": "LCS-WHT",
        "url": "https://shop.example.com/products/linen-camp-collar-shirt",
        "price": 89,
        "currency": "USD",
        "market": "us",
        "marketExact": true,
        "onSale": true,
        "discount": 20,
        "referencePrice": 119,
        "referenceCurrency": "CAD",
        "description": "<p>Breathable linen with a relaxed camp collar.</p>",
        "careInstructions": "Machine wash cold",
        "variants": [
          {
            "variantId": "gid://shopify/ProductVariant/41611609636909",
            "sku": "LCS-WHT-M",
            "label": "M / White",
            "options": [
              {
                "name": "Size",
                "value": "M"
              },
              {
                "name": "Color",
                "value": "White"
              }
            ],
            "inStock": true,
            "quantity": 4,
            "price": 89,
            "compareAtPrice": 111,
            "referencePrice": 119
          }
        ],
        "assets": {
          "images": [
            {
              "originalImageUrl": "https://shop.example.com/cdn/lcs-wht-1.jpg",
              "type": "model full body",
              "uploaded": {
                "id": "5d6e7f80-1a2b-4c3d-9e8f-7a6b5c4d3e2f",
                "key": "images/5d6e7f80-1a2b-4c3d-9e8f-7a6b5c4d3e2f.jpeg",
                "url": "https://stylor.ai/api/v1/image/images/5d6e7f80-1a2b-4c3d-9e8f-7a6b5c4d3e2f.jpeg"
              }
            }
          ],
          "generated": {
            "thumbnail": {
              "url": "https://stylor.ai/api/v1/image/thumbnails/5d6e7f80-1a2b-4c3d-9e8f-7a6b5c4d3e2f.jpeg"
            },
            "segmented": []
          }
        },
        "setItems": [],
        "recommendedItems": [],
        "model": {
          "height": "6'1\"",
          "size": "M"
        }
      },
      "metadata": {
        "description": "linen camp collar shirt shirts mens white relaxed collared button front short sleeve",
        "searchTextVersion": 2,
        "gender": "mens",
        "age_group": "adult",
        "color_rgb": [
          246,
          243,
          236
        ],
        "color_family": "white",
        "colors": [
          "white"
        ],
        "pattern": "solid",
        "material": [
          "linen"
        ],
        "fit": "relaxed",
        "slot": "top",
        "family": "shirt",
        "features": [
          {
            "axis": "neckline",
            "value": "collared",
            "confidence": 0.95
          },
          {
            "axis": "placket",
            "value": "button front",
            "confidence": 0.98
          },
          {
            "axis": "sleeve",
            "value": "short",
            "confidence": 0.99
          }
        ],
        "featureKeys": [
          "neckline:collared",
          "placket:button front",
          "sleeve:short"
        ],
        "taxonomyVersion": 4
      },
      "origin": {
        "siteUrl": "https://shop.example.com",
        "brandId": "example",
        "brand": "Example Co.",
        "country": "CA"
      },
      "createdAt": "2026-05-02T14:11:08.000Z",
      "updatedAt": "2026-08-19T09:40:22.000Z",
      "score": 0.4812,
      "scoring": {
        "vector": 1,
        "metadata": 0.6667,
        "price": 0,
        "final": 1.1333
      },
      "vecNorm": 1,
      "metaBonus": 0.6667,
      "blendedScore": 1.1333
    }
  ],
  "relevance": {
    "top": 0.4812,
    "weak": false,
    "candidates": 142,
    "aboveFloor": 9
  },
  "warnings": [
    {
      "message": "Dataset \"c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d\" is still in draft mode and may not have complete or production-ready data."
    }
  ]
}