# Search items

`POST https://stylor.ai/api/v1/search`

- Authentication: public API key, sent as `Authorization: Bearer sgpt-pk-…`
- Rate-limit resource: `api.v1.search.query`
- Demo key: accepted
- Web page: https://stylor.ai/guides/rest/v1/search-items

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.

## Example request

```bash
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
}'
```

## Request body

Content type `application/json`, required.

- `query` (string, required): 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). [min length 1; max length 512; example `"relaxed linen shirt for a summer wedding"`]
- `datasetIds` (array<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 `[]`]
- `filters` (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.
  - `gender` (string): **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 of `"mens"`, `"womens"`, `"unisex"`; example `"womens"`]
  - `slot` (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`. [example `"outerwear"`]
    - Option 1: string
    - Option 2: array<string>
  - `family` (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.
    - Option 1: string
    - Option 2: array<string>
  - `age_group` (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`. [example `"adult"`]
    - Option 1: string
    - Option 2: array<string>
  - `color_family` (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).
    - Option 1: string
    - Option 2: array<string>
  - `color_rgb` (array<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. [min items 3; max items 3]
  - `fit` (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.
    - Option 1: string
    - Option 2: array<string>
  - `pattern` (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. [example `"solid"`]
    - Option 1: string
    - Option 2: array<string>
  - `material` (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.
    - Option 1: string
    - Option 2: array<string>
  - `features` (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`.
    - Option 1: string
    - Option 2: array<string>
  - `exclude_terms` (array<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_terms` (array<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.
  - `inStock` (boolean): **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`. [example `true`]
  - `onSale` (boolean): **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. [example `true`]
  - `minPrice` (number): 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`. [example `60`]
  - `maxPrice` (number): Upper bound of the preferred price band, in the same currency as `minPrice`. Enables price scoring when sent alone or with `minPrice`. [example `140`]
  - `pricePenalty` (number): 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 `1`; example `1.5`]
  - `strictPriceFilter` (boolean): When `true` and a price band is set, items outside the band — and items with no price — are **removed** instead of penalised. [default `false`; example `true`]
- `country` (string, nullable): 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}$`; example `"US"`]
- `limit` (integer): 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 `5`; minimum 1; maximum 100; example `10`]
- `chatId` (string, nullable): Id of a chat owned by your organization. When sent, the chat must exist; combine with `slotId` to store the results in the conversation. [example `"8b4d3f6e-2c1a-4f0e-9d7b-5a6c1e2f3a4b"`]
- `slotId` (string, nullable): 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. [example `"3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9"`]

### Examples

#### Basic

A query with gender, family and colour filters.

```json
{
  "query": "relaxed linen shirt for a summer wedding",
  "filters": {
    "gender": "mens",
    "family": "shirt",
    "color_family": [
      "white",
      "beige"
    ]
  },
  "limit": 10
}
```

#### Price band

Favour a price range without excluding items outside it.

```json
{
  "query": "dark wash slim jeans",
  "filters": {
    "gender": "mens",
    "family": "pant",
    "minPrice": 60,
    "maxPrice": 140,
    "pricePenalty": 1.5
  },
  "limit": 20
}
```

#### Price cap

Drop items over a price and results with certain words.

```json
{
  "query": "everyday cotton tee",
  "filters": {
    "family": [
      "t shirt",
      "tank top"
    ],
    "maxPrice": 40,
    "strictPriceFilter": true,
    "exclude_terms": [
      "cropped",
      "graphic"
    ]
  },
  "limit": 15
}
```

#### Colour and keywords

Target an exact colour and boost matching terms.

```json
{
  "query": "navy wool blazer",
  "filters": {
    "gender": "mens",
    "family": [
      "blazer",
      "jacket"
    ],
    "color_family": "navy",
    "color_rgb": [
      18,
      32,
      72
    ],
    "boost_terms": [
      "wool",
      "unstructured",
      "half-lined"
    ],
    "fit": [
      "slim",
      "regular"
    ],
    "features": [
      "outerwear closure:single breasted"
    ]
  },
  "limit": 8
}
```

#### Shopper's country

Price results in the shopper's market and keep only items on sale.

```json
{
  "query": "linen shirt",
  "country": "US",
  "filters": {
    "gender": "mens",
    "family": "shirt",
    "maxPrice": 120,
    "onSale": true
  },
  "limit": 10
}
```

#### Chat slot

Search for a chat and fill one of its slots.

```json
{
  "query": "chunky white sneakers",
  "chatId": "8b4d3f6e-2c1a-4f0e-9d7b-5a6c1e2f3a4b",
  "slotId": "3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9",
  "datasetIds": [
    "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
  ],
  "filters": {
    "gender": "womens",
    "family": "sneaker",
    "age_group": "adult",
    "color_family": "white"
  },
  "limit": 5
}
```

## Responses

### 200

Ranked results, plus any non-fatal warnings.

Content type `application/json`:

- `results` (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.
  - `itemId` (string (uuid), required): Unique item id. It is the same id the queue entry was given at upload time. [example `"8b0a1b62-7c2a-4b7f-9e2c-0f3a9b1f0c11"`]
  - `datasetId` (string (uuid), required): The dataset the item belongs to. [example `"c9b3c6c0-9b0f-4f42-bf3f-2b7a6b1b9d31"`]
  - `organizationId` (string (uuid), required): The organization that owns the dataset. [example `"0a2c1f4e-5b6d-7e8f-9012-3456789abcde"`]
  - `status` (string, required): Only `active` items are listed and searchable. [one of `"active"`, `"archived"`; example `"active"`]
  - `version` (number, required): Ingest pipeline version the item was processed with. [example `1`]
  - `metadata` (DatasetItemMetadata, required): 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.
    - `description` (string): The text the item is embedded as for search: its product name and product type, then its labelled attributes, most distinguishing first, with nothing said twice. Not prose. `searchTextVersion` says which recipe built it; items processed before September 2026 hold a one-sentence summary instead. [default `""`; max length 400; example `"lucile mini dress dresses womens black slim satin mini length cowl neck sleeveless zip closure"`]
    - `gender` (string, required): Target gender. If the dataset is configured with a target gender (`mens` or `womens`), every item takes that value. [one of `"mens"`, `"womens"`, `"unisex"`; example `"womens"`]
    - `age_group` (string, nullable): Who the item is sized for, separate from gender (a girls' dress is `womens` and `kids`). `null` on items processed before September 2026. [one of `"adult"`, `"kids"`, `"baby"`, `null`; example `"adult"`]
    - `color_rgb` (array<integer>, required): The item's own colour as `[r, g, b]`, each 0–255, measured from its photos with the background removed. [min items 3; max items 3]
    - `color_family` (string, required): Colour bucket of the colourway this listing sells. [one of `"black"`, `"charcoal"`, `"grey"`, `"silver"`, `"white"`, `"ivory"`, `"cream"`, `"beige"`, `"tan"`, `"camel"`, `"brown"`, `"chocolate"`, `"khaki"`, `"olive"`, `"green"`, `"mint"`, `"teal"`, `"turquoise"`, `"blue"`, `"navy"`, `"purple"`, `"lavender"`, `"pink"`, `"rose"`, `"coral"`, `"peach"`, `"red"`, `"burgundy"`, `"rust"`, `"orange"`, `"mustard"`, `"yellow"`, `"gold"`, `"multicolour"`, `"other"`; example `"black"`]
    - `colors` (array<string>): Every colour the item is sold in, read from its colour options (lower-case, as the store names them); `[color_family]` when it has none.
    - `pattern` (string, required): Pattern printed or woven into the fabric. [one of `"solid"`, `"striped"`, `"checked"`, `"plaid"`, `"gingham"`, `"floral"`, `"ditsy floral"`, `"animal print"`, `"geometric"`, `"abstract"`, `"polka dot"`, `"paisley"`, `"camouflage"`, `"chevron"`, `"herringbone"`, `"tie dye"`, `"colour block"`, `"graphic"`, `"other"`; example `"solid"`]
    - `material` (array<string>): Fibres the listing states, main fabric first. Empty when the listing does not say. [one of `"cotton"`, `"linen"`, `"hemp"`, `"wool"`, `"cashmere"`, `"alpaca"`, `"mohair"`, `"silk"`, `"down"`, `"leather"`, `"suede"`, `"faux leather"`, `"shearling"`, `"polyester"`, `"nylon"`, `"elastane"`, `"acrylic"`, `"viscose"`, `"modal"`, `"lyocell"`, `"acetate"`, `"metal"`, `"wood"`, `"glass"`, `"ceramic"`, `"rubber"`, `"straw"`, `"paper"`, `"other"`]
    - `fit` (string, nullable): How a garment is cut relative to the body. `null` for anything without a fit (footwear, bags, jewellery). [one of `"skinny"`, `"slim"`, `"regular"`, `"relaxed"`, `"oversized"`, `"other"`, `null`; example `"slim"`]
    - `slot` (string, required): Where the item is worn, or what kind of thing it is. The primary search filter. [one of `"accessory"`, `"bag"`, `"jewellery"`, `"headwear"`, `"eyewear"`, `"hosiery"`, `"bottom"`, `"footwear"`, `"outerwear"`, `"full body"`, `"sleepwear"`, `"swimwear"`, `"non fashion"`, `"top"`, `"underwear"`; example `"full body"`]
    - `family` (string): What kind of thing it is within its slot, for example `dress`, `boot`, `t shirt`; `other` when the taxonomy has no name for it. [example `"dress"`]
    - `features` (array<object>): How the item varies, one answer per attribute that applies to its family (a dress has a neckline and a length; a scarf has neither).
      - `axis` (string) [example `"neckline"`]
      - `value` (string) [example `"cowl"`]
      - `confidence` (number, nullable): The labeller's probability for this answer, 0–1. [example `0.86`]
    - `featureKeys` (array<string>): `features` flattened to sorted `axis:value` strings, for filtering.
    - `taxonomyVersion` (integer, nullable): The taxonomy version the item was labelled under. [example `4`]
    - `searchTextVersion` (integer, nullable): Which recipe built `description`. `2` leads with the product name and type (since September 2026); `null` on items embedded before versions were recorded. An item is re-embedded under the current recipe when it is next reprocessed. [example `2`]
  - `product` (DatasetItemProduct, required): Merchant-facing product data. Everything here comes from your upload or your dataset configuration, not from AI.
    - `sku` (string, required): The product-level SKU you supplied. Unique within a dataset. [example `"A1A2P-BB2J"`]
    - `url` (string (uri), required): Product page URL, built as `<dataset store URL>/products/<handle>`. [example `"https://shop.example.com/products/classic-crew-tee"`]
    - `name` (string, required): Product title. [example `"Classic Crew Tee"`]
    - `description` (string): Product description as supplied; may contain HTML. [default `""`; example `"<p>A heavyweight organic cotton tee with a relaxed fit.</p>"`]
    - `careInstructions` (string): Care instructions. Not populated by the ingest queue. [default `""`; example `""`]
    - `price` (number, required): Product price, in the market named by `market`: as stored, the store's default market; on a search result, the shopper's (see `referencePrice`). `0` when the upload had no price. [example `49`]
    - `currency` (string, required): ISO 4217 code of `price`. As stored, the currency the source reports (a Shopify Storefront API source reports its own), falling back to the dataset's configured currency, then `CAD`. On a search result, the shopper's market's currency. [example `"USD"`]
    - `market` (string, nullable): Handle of the store price list the prices, sale prices and stock are in, such as `us`, `ca` or `international-xof` (a Shopify market, split by currency when one market charges several). Stored on every item: the store's default market, or `null` for sources with no markets (uploads, a store's public product feed). On a search result, the market the product was priced for. [example `"us"`]
    - `marketExact` (boolean): Response only, on search results. `true` when the prices are the shopper's own market's. `false` when the store's default market's prices are standing in, with the reason in `marketFallback`. [example `true`]
    - `marketFallback` (string): Response only, present when `marketExact` is `false`. Why the shopper's own market could not be answered. `no-country`: the request sent no `country`. `not-served`: the store does not sell into that country. `unread`: the shopper's market could not be read. `no-row`: the product has no prices stored for that market yet (synced before the market existed). `no-markets`: the source has no markets. `stale`: the stored market prices were built against a different default market than the product is in. [one of `"no-country"`, `"not-served"`, `"unread"`, `"no-row"`, `"no-markets"`, `"stale"`; example `"no-country"`]
    - `available` (boolean): Response only, on search results. `true` when the shopper can buy it: sold in their market, with at least one variant in stock there. The Stylor agent never recommends a product for which this is `false`. [example `true`]
    - `onSale` (boolean): Response only, on search results. `true` when a variant the shopper can buy (in stock) has a `compareAtPrice` above its `price` in the shopper's market. A reduced price on a sold-out variant does not count. [example `true`]
    - `discount` (integer): Response only, on search results. The largest percent off among in-stock variants, rounded; `0` when `onSale` is `false`. [example `20`]
    - `referencePrice` (number): Response only, on search results. The stored price in the store's default market, before re-pricing for the shopper. [example `49`]
    - `referenceCurrency` (string): Response only, on search results. The currency of `referencePrice`. [example `"USD"`]
    - `model` (DatasetItemModelInfo): Details of the model shown in the product photography, when known. Empty strings when not provided.
      - `height` (string): Model height, free text. [default `""`; example `""`]
      - `size` (string): Size worn by the model. [default `""`; example `""`]
    - `setItems` (array<string>): SKUs of products sold together with this one. Not populated by the ingest queue.
    - `recommendedItems` (array<string>): SKUs of merchant-recommended companions. Not populated by the ingest queue.
    - `assets` (DatasetItemAssets): Images attached to the item.
      - `images` (array<DatasetItemImage>): Stored product images, in the order they were supplied. At most 10 images are kept per item; any beyond the tenth are dropped at ingest. [max items 10]
        - `originalImageUrl` (string (uri), required): The `src` you supplied at upload time. [example `"https://cdn.shopify.com/s/files/1/0001/products/tee-front.jpg"`]
        - `type` (string): What the photo shows: one of `flat front`, `flat back`, `mannequin`, `underside`, `detail`, `model full body`, `model cropped`, `hand`, `group`, `scene`, `not product`. Items processed before September 2026 carry an older vocabulary (`packshot`, `model_front`, `lifestyle`, …). [default `""`; example `"model full body"`]
        - `uploaded` (StoredImage, required): A copy of an image that Stylor stores itself. Only the object `key` is returned; turn it into a URL by prefixing the image delivery endpoint: `https://stylor.ai/api/v1/image/{key}`. Append transform segments such as `/-/resize/600x/-/format/webp` to that URL to get a resized or re-encoded variant.
          - `id` (string (uuid), required): Identifier of the stored copy. It is the UUID portion of `key`. [example `"3f9c2a1e-8d4b-4c6f-9a2e-1b7d5e0c4f88"`]
          - `key` (string, required): Object key to pass to `GET /v1/image/{key}`. Catalogue images use the form `images/<uuid>.jpeg`. [example `"images/3f9c2a1e-8d4b-4c6f-9a2e-1b7d5e0c4f88.jpeg"`]
      - `generated` (DatasetItemGeneratedAssets): Assets Stylor generates for an item. Generative assets are currently disabled platform-wide, so new items carry a `null` thumbnail URL and an empty `segmented` list.
        - `thumbnail` (object): An AI-generated product thumbnail.
          - `url` (string, nullable): Absolute URL of the thumbnail on the image delivery endpoint, or `null` when none was generated. [example `null`]
        - `segmented` (array<DatasetItemSegmentedImage>): Background-removed cut-outs.
          - `sourceImageId` (string, required): The `uploaded.id` of the product image the cut-out was made from. [example `"3f9c2a1e-8d4b-4c6f-9a2e-1b7d5e0c4f88"`]
          - `uploaded` (StoredImage, required): A copy of an image that Stylor stores itself. Only the object `key` is returned; turn it into a URL by prefixing the image delivery endpoint: `https://stylor.ai/api/v1/image/{key}`. Append transform segments such as `/-/resize/600x/-/format/webp` to that URL to get a resized or re-encoded variant.
            - `id` (string (uuid), required): Identifier of the stored copy. It is the UUID portion of `key`. [example `"3f9c2a1e-8d4b-4c6f-9a2e-1b7d5e0c4f88"`]
            - `key` (string, required): Object key to pass to `GET /v1/image/{key}`. Catalogue images use the form `images/<uuid>.jpeg`. [example `"images/3f9c2a1e-8d4b-4c6f-9a2e-1b7d5e0c4f88.jpeg"`]
    - `variants` (array<DatasetItemVariant>): Purchasable variants. Renamed from `sizes`, which is what the field had always held: a variant is the product of every option axis, so these rows are `S / Cream` and `Regular / XXS-UK6` — size crossed with colour, or with fit — not sizes.
      - `variantId` (string): The storefront's own variant id, when the source reported one. Empty for catalogues read from a store's public product feed, which does not return it. [example `"gid://shopify/ProductVariant/41611609636909"`]
      - `sku` (string, required): The variant SKU, exactly as supplied. [example `"A1A2P-BB2J-M"`]
      - `label` (string, required): The variant's full name, for example `Medium` or `M / Black`. [example `"M / Black"`]
      - `options` (array<object>): Each option axis separately, which `label` cannot reliably be parsed back into. Axis names are merchant-authored and their casing is not dependable — one store writes `Size` and `Color`, another `size` and `fit`. Match case-insensitively.
        - `name` (string) [example `"Color"`]
        - `value` (string) [example `"Black"`]
      - `inStock` (boolean, required): The variant's availability at the last sync, in the market the product is priced in: the store's default market as stored, the shopper's own on a search result. [example `true`]
      - `quantity` (integer, nullable): Units in stock, or `null` when the source did not report a count. `null` and `0` are different answers. A public product feed never reports a count; a Storefront API source reports one only when its token carries the inventory scope. [example `4`]
      - `price` (number, required): Variant price in the product's `currency`. On a search result, the shopper's market's price (see `DatasetItemProduct.market`). [example `49`]
      - `compareAtPrice` (number, nullable): The variant's list price when it is reduced, otherwise `null`. In the same market as `price`. [example `79`]
      - `referencePrice` (number, nullable): Response only, on search results. The variant's stored price in the store's default market (in the product's `referenceCurrency`), present when the variant was re-priced for the shopper's market. Never stored. [example `49`]
      - `imageRef` (string, nullable): This variant's own photo, when the source distinguishes one from the product's.
  - `origin` (DatasetItemOrigin, required): Where the item came from. Copied from the dataset's store configuration at processing time.
    - `siteUrl` (string): Store URL from the dataset configuration. [default `""`; example `"https://shop.example.com"`]
    - `brandId` (string): Store identifier from the dataset configuration. [default `""`; example `"shop-example"`]
    - `brand` (string): Store or brand name. [default `""`; example `"Example Apparel"`]
    - `country` (string): Store country from the dataset configuration. [default `""`; example `"CA"`]
  - `createdAt` (string (date-time), required): When the processed item was first written. [example `"2025-03-04T18:22:41.913Z"`]
  - `updatedAt` (string (date-time), required): When the item was last rewritten by a sync or reprocess. [example `"2025-03-04T18:22:41.913Z"`]
  - `score` (number, required): Raw cosine similarity from the vector index, before normalisation. Comparable across searches, unlike `scoring.vector`. [example `0.4812`]
  - `scoring` (SearchScoring, required): Per-result breakdown of the ranking formula. Components that were not active for this request are omitted.
    - `vector` (number, required): Cosine similarity to the query divided by the best candidate's (0.65–1). The best candidate is always `1`. [example `0.92`]
    - `metadata` (number, required): Normalised agreement with the soft filters (−1 to 1). `0` when no soft filters were sent. [example `0.67`]
    - `price` (number, required): `0` inside the band (or with no band, or no price); a penalty down towards −1 outside it. [example `0`]
    - `rgbColor` (number): Colour closeness to `filters.color_rgb` (−1 to 1). Only present when `color_rgb` was sent. [example `0.62`]
    - `keywordBoost` (number): Fraction of `filters.boost_terms` found in the name or description (0–1). Only present when `boost_terms` was sent. [example `0.5`]
    - `final` (number, required): `vector` plus the weighted components above (see Scoring). Results are sorted by this value, descending. [example `1.05`]
  - `vecNorm` (number, required): Legacy alias of `scoring.vector`. [example `0.92`]
  - `metaBonus` (number, required): Legacy alias of `scoring.metadata`. [example `0.67`]
  - `blendedScore` (number, required): Legacy alias of `scoring.final`. [example `0.74`]
- `relevance` (SearchRelevance, required): How well the best candidate matched the query. A signal for how to present the results; nothing is dropped because of it.
  - `top` (number, nullable, required): The best candidate's raw cosine similarity, after the hard filters. `null` when there were no candidates. [example `0.4812`]
  - `weak` (boolean, required): `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. [example `false`]
  - `candidates` (integer, required): Candidates that passed the hard filters. [example `142`]
  - `aboveFloor` (integer, required): Candidates that cleared the relevance floor; `results` holds up to `limit` of them. [example `9`]
- `parsedQuery` (object): Only present when negations were read out of `query`. What was searched for, and the terms added to `exclude_terms`.
  - `query` (string, required): The text that was embedded, with the negated phrases removed. The original query when nothing else would have been left. [example `"dress"`]
  - `exclude_terms` (array<string>, required): Terms read from the query and excluded, in addition to any sent in `filters.exclude_terms`.
- `warnings` (array<string | object>): Only present when at least one warning was raised.

Example:

```json
{
  "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."
    }
  ]
}
```

## Errors

Every error is a JSON object with an `error` string. Match on the status code; the message is written for people.

| Status | Message | When |
| --- | --- | --- |
| 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. |

### Shared authentication, quota and rate-limit errors

| Status | Message | When |
| --- | --- | --- |
| 401 | API key missing. | The `Authorization` header is absent, is not `Bearer <key>`, or the key does not have three dash-separated segments. |
| 401 | Authentication failed: public key is invalid or not recognized. | A public-key endpoint was called with a key that does not exist. |
| 401 | Authentication failed: private key provided instead of a public key. | A public-key endpoint was called with an `sgpt-sk-…` key. |
| 401 | Authentication failed: token is not recognized. | A private-key endpoint was called with a key whose lookup segment does not exist. |
| 401 | Authentication failed: private key is invalid. | A private-key endpoint was called with a key whose secret does not match. |
| 401 | Authentication failed: public key provided instead of a private key. | A private-key endpoint was called with an `sgpt-pk-…` key. |
| 401 | Authentication failed: bearer token format is invalid. | A private key was sent without its `::` lookup segment. |
| 401 | Authentication failed: API key could not be read. Regenerate the key. | The key's embedded organization data could not be decrypted or parsed. |
| 403 | Demo mode is not available for private-key routes. Demos are only supported with public API keys. | The demo key was sent to a private-key endpoint. |
| 403 | Demo mode is not supported on this API route. Check the documentation for available demo endpoints. | The demo key was sent to a public-key endpoint that opts out of demo mode. |
| 403 | No active subscription for this organization. | The organization that owns the key has no active subscription. |
| 403 | No quota found for organization "<organizationId>". | The organization has no quota record for the current billing period. |
| 403 | No entitlement for "<resource>". | The plan's quota template has no entry for this endpoint. |
| 403 | Access denied to "<resource>". | The plan's quota template turns this endpoint off. |
| 429 | Monthly quota exceeded for "<resource>" (<quota>/<quota> used). | The billing-period quota for this endpoint is exhausted. `Retry-After` gives the seconds until the period resets. |
| 429 | RPM limit exceeded for "<resource>" (<used>/<limit> used). | More than the allowed requests per minute were sent. `Retry-After` is 60. |
| 500 | Internal server error. | An unexpected failure on the server. The message is always this string; details are logged, never returned. Quota consumed by the request is refunded, as it is for every 5xx response. |
