> ## Documentation Index
> Fetch the complete documentation index at: https://docs.depict.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom search UI: API reference

> Endpoints, request and response fields for the Depict storefront search API: search, filters, pagination, query suggestions and empty state.

<Warning>
  **This API will be removed at the end of this year.** It is the
  current-generation storefront search API, and it is the supported and
  recommended way to build a custom search UI today — the Depict search modal
  runs on exactly these endpoints. A successor API is coming, and every team
  building on this one should plan for a migration before year end. Migration
  documentation will follow; ask your Depict contact to be notified when it is
  published.
</Warning>

Base URL for every endpoint on this page:

```
https://runway.depict.ai
```

The read endpoints take no credentials. Every request carries `merchant_id`,
`locale` and `country`. See the
[quickstart](/shopify-search/custom-search-ui/quickstart) for where those
values come from.

## REST or streaming?

Both, in a specific sense, and the distinction decides how you write the
client.

**The request is REST-style.** `POST /search_v2` is an ordinary HTTPS request
with a JSON body. Each page of results is its own request, nothing stays open
between requests, and there is no WebSocket, no Server-Sent Events and nothing
to subscribe to. Any HTTP client works, `curl` included.

**The response body is streamed.** The server answers `200` with
`Content-Type: application/x-ndjson` and `Transfer-Encoding: chunked`, and
writes the body incrementally as each part of the answer is ready — the filter
and sort metadata first, then the products, then the pagination line, then any
conversational keys your account has enabled. Bytes reach you before the
response is complete. The body is deliberately uncompressed
(`Content-Encoding: identity`) so that no compressor buffers it.

The body is newline-delimited lines of the form `key:json`:

```
filter_facets:["colors","material","gender","product_type"]
sorts:[{"field":"_relevance","order":"desc","meta":{"values":["desc"]}}]
keyword:[{"id":"9606764101971","title":"Karlberg - Black Calf","price":249.0, ...}]
keyword_page_info:{"page":1,"has_next_page":true,"has_prev_page":false,"is_fallback":false,"n_hits":33}
```

In spite of the content type, a line is **not** a JSON document on its own: it
is a key, a colon, then a JSON value. Split each line on its **first** colon,
and do not call `response.json()` on the body.

You can consume it either way. Both handle every response, including a cached
one, which can arrive as a single chunk.

<CodeGroup>
  ```javascript Await the whole body theme={null}
  // Simplest. Correct for every key. The only cost is first paint: you render
  // after the last byte instead of after the products line.
  async function search(body) {
    const response = await fetch("https://runway.depict.ai/search_v2", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!response.ok) throw new Error(`Search failed: ${response.status}`);

    const events = {};
    for (const line of (await response.text()).split("\n")) {
      if (!line) continue;
      const colon = line.indexOf(":");
      const key = line.slice(0, colon);
      // Fragment keys (chat_text, content, content_answer) repeat across lines;
      // concatenating per key is correct for single-line keys too.
      events[key] = (events[key] ?? "") + line.slice(colon + 1);
    }
    // The HTTP status is 200 even when the search failed mid-stream.
    if (events.error) throw new Error(JSON.parse(events.error).error_hint);

    return {
      products: JSON.parse(events.keyword),
      pageInfo: JSON.parse(events.keyword_page_info),
      filterFacets: JSON.parse(events.filter_facets),
      sorts: JSON.parse(events.sorts),
    };
  }
  ```

  ```javascript Read incrementally theme={null}
  // Render the products as soon as their line has arrived.
  async function search(body, onEvent) {
    const response = await fetch("https://runway.depict.ai/search_v2", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!response.ok || !response.body) {
      throw new Error(`Search failed: ${response.status}`);
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    const events = {};
    let buffer = "";

    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      // A chunk boundary can fall anywhere, so only consume complete lines.
      let newline;
      while ((newline = buffer.indexOf("\n")) !== -1) {
        const line = buffer.slice(0, newline);
        buffer = buffer.slice(newline + 1);
        const colon = line.indexOf(":");
        const key = line.slice(0, colon);
        events[key] = (events[key] ?? "") + line.slice(colon + 1);

        try {
          onEvent(key, JSON.parse(events[key]));
        } catch {
          // A fragment key that is not complete JSON yet — wait for the next line.
        }
      }
    }
  }

  await search(body, (key, value) => {
    if (key === "keyword") renderProducts(value); // first paint
    if (key === "keyword_page_info") setPageInfo(value);
    if (key === "error") showError(value.error_hint);
  });
  ```
</CodeGroup>

**A failure mid-stream** keeps the `200` status, because the headers were sent
before the search ran. It arrives as an `error` line, after the metadata lines
and instead of the product lines:

```
filter_facets:["colors","material","gender","product_type"]
sorts:[{"field":"_relevance","order":"desc","meta":{"values":["desc"]}}]
error:{"error_hint":"Search retrieval failed"}
```

Nothing useful follows it. A whole-body client checks for `error` before reading
`keyword`; an incremental client stops rendering when it sees the key. See
[Errors](#errors) for the request-level statuses and how the Depict modal treats
the two cases differently.

## POST /search\_v2

Runs a search and streams results. Responds with
`Content-Type: application/x-ndjson`.

### Request body

<ResponseField name="query" type="string" required>
  The shopper's search query.
</ResponseField>

<ResponseField name="merchant_id" type="string" required>
  Your merchant id.
</ResponseField>

<ResponseField name="locale" type="string" required>
  Storefront language, for example `en`.
</ResponseField>

<ResponseField name="country" type="string" required>
  ISO 3166-1 alpha-2 country code, for example `US`. Resolved to one of your
  Shopify markets, which determines price, currency and availability. An
  unmapped country returns `404`.
</ResponseField>

<ResponseField name="session_id" type="string" required>
  One id per browser tab, stable across searches. Generate a random id and keep
  it in `sessionStorage`.
</ResponseField>

<ResponseField name="search_id" type="string" required>
  Identifies one search. Generate a new id when the shopper submits a query;
  reuse it for the pages of that search and for every tracking event about its
  results. Changing a filter is a **new** search and takes a new id — the
  result set is different, and reusing the id would collapse two of them into
  one row in your analytics.
</ResponseField>

<ResponseField name="page" type="integer" default="1">
  1-based page number. Page `n` is products `(n − 1) × limit + 1` to
  `n × limit` of the ranked results, so page 2 with the default `limit` is
  products 31–60. See [Pagination](#pagination).
</ResponseField>

<ResponseField name="limit" type="integer" default="30">
  Products per page, `1`–`250`. The Depict modal uses the default. A value out
  of range returns `422` naming the field — the request is never silently
  clamped. Keep it constant across the pages of one search: page 2 is "the next
  `limit` products after page 1", so changing it between pages repeats or skips
  products.
</ResponseField>

<ResponseField name="filters" type="Filter[]">
  Filters to apply. See [Filters](#filters). Omit the field entirely when there
  are none.
</ResponseField>

<ResponseField name="sort" type="SortModel">
  Server-side ordering: `{ "field": "created", "order": "desc" }`. The offered
  combinations are advertised in the response's `sorts:` line — read them from
  there (`meta.values` lists the allowed orders per field). An unoffered
  field or order returns `422` with a message naming what is offered; the
  request is never silently answered in a different order. A `meta` object
  echoed back from the advertisement is accepted and ignored. Omit the field
  for relevance order.
</ResponseField>

<ResponseField name="device_id" type="string">
  Stable per-browser id, kept in `localStorage`. Lets analytics count returning
  visitors instead of counting every session as a new one.
</ResponseField>

<ResponseField name="entry_point" type="string">
  Free-form label for the page the search started from, for analytics. The
  Depict modal sends one of `product`, `products_listing`, `collection`,
  `collections_listing`, `search`, `content`, `cart`, `home`, `other`,
  `unknown`.
</ResponseField>

<ResponseField name="history" type="SearchHistoryData[]">
  Prior turns of a conversational search, each `{ query, chat_text }`. Only
  relevant when the conversational answer layer is enabled for your account.
</ResponseField>

<ResponseField name="previous_search_id_in_conversation" type="string">
  The `search_id` of the previous turn, for conversational analytics.
</ResponseField>

### Response format

The body is a sequence of lines, each `key`, `:`, then a JSON fragment,
terminated by `\n`:

```
filter_facets:["colors","material","product_type"]
sorts:[{"field":"_relevance","order":"desc","meta":{"values":["desc"]}},{"field":"price","order":"asc","meta":{"values":["asc","desc"]}}]
keyword:[{"id":"9606764101971", ...}]
keyword_page_info:{"page":1,"has_next_page":true,"has_prev_page":false,"is_fallback":false,"n_hits":33}
```

Most keys are emitted once, with a complete JSON value on a single line. The
conversational keys (`chat_text`, `content`, `content_answer`) are streamed
token by token: the same key appears on many lines, each carrying a **fragment**
of one JSON object. Concatenate the fragments for a key, in order, and parse
when the result becomes valid JSON. The parser in the
[quickstart](/shopify-search/custom-search-ui/quickstart#1-run-a-search)
handles both cases with the same code path.

Ignore keys you do not recognise. New keys are added to this stream additively,
and a client that ignores unknown keys keeps working.

### Stream keys

| Key                           | Value                                                                                                                          | When                                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `filter_facets`               | `string[]` of filter field names enabled for your account                                                                      | Always, first                                                                                       |
| `sorts`                       | `[{ field, order, meta: { values } }]` — the sort options offered, for rendering a sort control and validating `sort` requests | Always                                                                                              |
| `facet_counts`                | `{ field: [{ value, count }] }`                                                                                                | Only for server-counted facets (currently `size`), when retrieval produced counts                   |
| `keyword`                     | `Product[]` — the results                                                                                                      | Always                                                                                              |
| `keyword_page_info`           | `{ page, has_next_page, has_prev_page, is_fallback, n_hits }` — see [Pagination](#pagination)                                  | Always, after `keyword`                                                                             |
| `error`                       | `{ error_hint }`                                                                                                               | Retrieval failed. Ends the useful part of the stream                                                |
| `chat_text`                   | Fragments of `{ chat_text }`                                                                                                   | Only when the conversational answer is enabled                                                      |
| `follow_up_query_suggestions` | `string[]`                                                                                                                     | Only when follow-up suggestions are enabled                                                         |
| `content_meta`                | `{ display: "primary" \| "secondary" }`                                                                                        | Only when content-page results are enabled. Where to place the content section relative to the grid |
| `content_pages`               | `[{ title, url, snippet, vector_distance }]`                                                                                   | Content-page results, alongside the product grid                                                    |
| `content_answer`              | Fragments of `{ chat_text }`                                                                                                   | Generated answer about content pages, rendered inside the content section                           |
| `content`                     | Fragments of `{ chat_text }`                                                                                                   | Legacy content answer that **replaces** the product grid. Only for accounts still on that behaviour |

<Note>
  In the default configuration a response contains exactly `filter_facets`,
  `sorts`, `keyword` and `keyword_page_info`, in that order. `sorts` is on
  **every** response — it is emitted together with `filter_facets` before
  retrieval runs, so it is present even when the search fails and an `error`
  line follows. The conversational and content keys are per-account features —
  check what your account returns before building UI that depends on them.
</Note>

### Product object

Every product in `keyword`, and in the empty-state response, has this shape.

<ResponseField name="id" type="string" required>
  Product id. This is the id to use in tracking events.
</ResponseField>

<ResponseField name="grouping_id" type="string | null">
  Id of the product group this result belongs to, when your catalogue is
  grouped (for example one card per style, with `id` naming the variant that
  was served). On an ungrouped catalogue it is `null`. `id` and `grouping_id`
  are different id spaces: use `id` for tracking and product URLs, and
  `grouping_id` only when you need to address the whole group — it cannot be
  derived from `id`. The Depict modal does not read it.
</ResponseField>

<ResponseField name="title" type="string" required>
  Product title.
</ResponseField>

<ResponseField name="price" type="number" required>
  Current price in the market's currency.
</ResponseField>

<ResponseField name="image_urls" type="string[]" required>
  Image URLs, primary image first. **At most two** — the API caps the list, so
  do not build a gallery expecting every image. Can be empty.
</ResponseField>

<ResponseField name="currency" type="string">
  ISO currency code for `price` and `original_price`. Empty string when the
  catalogue has no currency for the product, so treat it as optional rather
  than assuming a code is always present.
</ResponseField>

<ResponseField name="handle" type="string | null">
  Shopify handle, for building the product URL.
</ResponseField>

<ResponseField name="variant_id" type="string | null">
  Variant id, when the result represents a specific variant.
</ResponseField>

<ResponseField name="variant_title" type="string | null">
  Variant title, for example a size or colour name.
</ResponseField>

<ResponseField name="original_price" type="number | null">
  Pre-discount price. Show a strikethrough when it differs from `price`.
</ResponseField>

<ResponseField name="on_sale" type="boolean | null">
  Whether the product is discounted in this market.
</ResponseField>

<ResponseField name="out_of_stock" type="boolean" default="false">
  Whether the product is unavailable in this market.
</ResponseField>

<ResponseField name="is_bestseller" type="boolean" default="false">
  Bestseller flag.
</ResponseField>

<ResponseField name="is_new" type="boolean" default="false">
  The stored "new in" flag — the value the `new` filter matches on. It is not
  the input for a client-side "new" badge: derive that from
  `published_or_created_at`, which is exact.
</ResponseField>

<ResponseField name="is_variable_price" type="boolean" default="false">
  `true` when `price` was synthesised by summing the product's components (a
  set or bundle priced from its parts) rather than quoted by the merchant. Do
  not display `price` when this is `true`; the Depict modal hides the price on
  these cards.
</ResponseField>

<ResponseField name="published_or_created_at" type="number | null">
  Unix timestamp in seconds. The Depict modal treats a product as "new" when
  this is within the last 30 days.
</ResponseField>

<ResponseField name="colors" type="string[]" default="[]">
  Colour values. Also a filterable field.
</ResponseField>

<ResponseField name="pattern" type="string" default="">
  Pattern value.
</ResponseField>

<ResponseField name="material" type="string" default="">
  Material value.
</ResponseField>

<ResponseField name="occasions" type="string[]" default="[]">
  Occasion values.
</ResponseField>

<ResponseField name="gender" type="string" default="">
  Gender value.
</ResponseField>

<ResponseField name="tags" type="string[]" default="[]">
  Tag values.
</ResponseField>

<ResponseField name="style_attributes" type="string[]" default="[]">
  Style attribute values.
</ResponseField>

<ResponseField name="debug_info" type="object | null">
  Always `null` in normal responses. Ignore it.
</ResponseField>

### Pagination

Pagination is page-based and stateless. Every page is a separate
`POST /search_v2` with a `page` number, the server keeps nothing between the
requests, and the `keyword_page_info` line of each response tells you whether
to ask for another.

```
keyword_page_info:{"page":2,"has_next_page":true,"has_prev_page":true,"is_fallback":false,"n_hits":33}
```

<ResponseField name="page" type="integer">
  Echo of the requested page.
</ResponseField>

<ResponseField name="has_next_page" type="boolean">
  `true` when this page came back **full**, i.e. with `limit` products. That is
  the whole rule — the server does not look ahead. Two consequences: when the
  total is an exact multiple of `limit`, the page after the last one comes back
  empty (`keyword:[]`, `has_next_page:false`) rather than being predicted; and
  requesting a page past the end is not an error — it is `200` with an empty
  `keyword`.
</ResponseField>

<ResponseField name="has_prev_page" type="boolean">
  `page > 1`.
</ResponseField>

<ResponseField name="n_hits" type="integer | null">
  Total matching products for the query and filters, independent of the page —
  the number for a "33 results" label. It is **not guaranteed exact**: the
  underlying grouped count can differ by a few between pages of the same
  search. Drive fetching from `has_next_page`, never from `n_hits ÷ limit`: on
  some account configurations the ranked list is capped at 250 products deep,
  so `has_next_page` can turn `false` before `n_hits` is reached. `null` when
  there is no honest total, which is the case for fallback results
  (`is_fallback: true`) and can be the case for a page past the end.
</ResponseField>

<ResponseField name="is_fallback" type="boolean">
  `true` when the query found no good match and a relaxed search answered
  instead. Present those results as close matches, not as exact hits.
</ResponseField>

#### The request for page N

Send exactly the body you sent for page 1, with `page` set to N. Every other
field stays the same: `query`, `filters`, `sort`, `limit`, `session_id` and
`search_id`. This is what the Depict modal does — it builds the body once per
search and only the page number changes between requests.

* **`search_id` is reused** for every page of one search. It groups the pages,
  and the clicks and impressions on them, into one search in analytics. The
  server does not use it to compute results, so a wrong id never changes what
  you get back — it only corrupts your analytics.
* **A change to anything other than `page` is a new search.** A new query, an
  added or removed filter or a different sort produces a different result set:
  start again at `page: 1` with a **new** `search_id`. The Depict modal mints a
  fresh id on every filter change for exactly this reason, and resets its page
  counter with it.
* **`limit` stays constant** within a search, for the reason given under
  [`limit`](#request-body) above.

#### Worked example: three pages of one search

A search for `black loafers` with `limit: 15` that matches 33 products takes
three requests. Only `page` differs between them; the product objects below
are abbreviated.

**Request 1**

```json theme={null}
{
  "query": "black loafers",
  "merchant_id": "YOUR_MERCHANT_ID",
  "locale": "en",
  "country": "GB",
  "session_id": "3f2c9a1e-7d0b-4c8e-9b7a-1d2e3f4a5b6c",
  "search_id": "c1a9e0b4-5d6f-4e7a-8b9c-0d1e2f3a4b5c",
  "page": 1,
  "limit": 15
}
```

```
filter_facets:["colors","material","gender","product_type"]
sorts:[{"field":"_relevance","order":"desc","meta":{"values":["desc"]}},{"field":"price","order":"asc","meta":{"values":["asc","desc"]}},{"field":"created","order":"desc","meta":{"values":["desc"]}}]
keyword:[{"id":"9606764101971","title":"Karlberg - Black Calf","price":249.0,"handle":"karlberg-black-calf","image_urls":["https://cdn.shopify.com/s/files/1/0001/0001/files/karlberg-1.jpg"], ...}, ... 15 products in total]
keyword_page_info:{"page":1,"has_next_page":true,"has_prev_page":false,"is_fallback":false,"n_hits":33}
```

The page is full, so `has_next_page` is `true`. Request page 2.

**Request 2** — the same body with `"page": 2`

```
filter_facets:["colors","material","gender","product_type"]
sorts:[ ... same as page 1 ... ]
keyword:[{"id":"9606764167507","title":"Ashford - Black Suede","price":279.0, ...}, ... 15 products in total]
keyword_page_info:{"page":2,"has_next_page":true,"has_prev_page":true,"is_fallback":false,"n_hits":33}
```

Full again. Request page 3.

**Request 3** — the same body with `"page": 3`

```
filter_facets:["colors","material","gender","product_type"]
sorts:[ ... same as page 1 ... ]
keyword:[{"id":"9606764232243","title":"Rutland - Black Calf","price":229.0, ...}, ... 3 products in total]
keyword_page_info:{"page":3,"has_next_page":false,"has_prev_page":true,"is_fallback":false,"n_hits":33}
```

Three products — fewer than `limit` — so `has_next_page` is `false` and the
client stops. Had the total been 45 instead, page 3 would have come back full
with `has_next_page: true`, and a fourth request would have returned
`keyword:[]` with `has_next_page: false`.

The metadata lines (`filter_facets`, `sorts`) repeat on every page; read them
from page 1 and ignore them afterwards.

In code, using the whole-body `search()` from
[REST or streaming?](#rest-or-streaming):

```javascript theme={null}
// `body` is built once per search: query, filters, sort, limit, session_id
// and a freshly generated search_id. Only `page` changes below.
let page = 1;
let hasNextPage = true;
while (hasNextPage) {
  const { products, pageInfo } = await search({ ...body, page });
  renderProducts(products); // append, or call this from a "load more" handler
  hasNextPage = pageInfo.has_next_page;
  page += 1;
}
```

### Filters

Send filters as an array on the search request. Each entry is
`{ field, operator, value }`.

```json theme={null}
{
  "query": "loafers",
  "merchant_id": "YOUR_MERCHANT_ID",
  "locale": "en",
  "country": "US",
  "page": 1,
  "session_id": "SESSION_ID",
  "search_id": "SEARCH_ID",
  "filters": [
    { "field": "colors", "operator": "in", "value": ["black"] },
    { "field": "price", "operator": "<=", "value": 500 },
    { "field": "on_sale", "operator": "=", "value": true }
  ]
}
```

**Operators:** `=`, `!=`, `>`, `<`, `>=`, `<=`, `in`, `not_in`.

**Fields:** `size`, `quantity_available`, `price`, `on_sale`,
`product_out_of_stock`, `is_bestseller`, `new`, `color`, `variant_color`,
`colors`, `color_details`, `pattern`, `material`, `occasions`, `gender`, `tags`,
`style_attributes`, `product_type`.

Only the fields listed in the `filter_facets` stream key are configured for your
account. A field can be indexed for your catalogue without appearing there, and
filtering on it works.

<Warning>
  Filtering on a field your catalogue's index does not have **fails the whole
  search** — you get an error, not an empty result set. Stick to the fields in
  `filter_facets` unless Depict has confirmed another one is indexed for you.
</Warning>

`value` is a string, number, boolean, or array of strings. The three boolean
badge filters used by the Depict modal — `on_sale`, `is_bestseller`, `new` — are
sent as `{ field, operator: "=", value: true }` and simply omitted when off.

<Warning>
  Filter values must be the exact values that appear on the products. Build your
  filter options from the values in the current result set (for example, collect
  every entry of `colors` across the loaded products), or from `facet_counts`
  where the backend supplies them. Do not invent or normalise values.
</Warning>

### Building facet options

`filter_facets` tells you **which** filters to show, not what to put in them.

* For most fields, derive the options from the products you have loaded:
  `colors`, `occasions`, `tags`, `style_attributes` are arrays on each product;
  `pattern`, `material`, `gender`, `product_type` are single values.
* `size` is different. Sizes live on variants, and results are grouped by
  product, so options cannot be derived from the product cards. When the facet
  is enabled the backend sends them in `facet_counts`:

  ```
  facet_counts:{"size":[{"value":"M","count":7},{"value":"L","count":3}]}
  ```

  These counts are catalogue-wide under the shopper's other filters, not scoped
  to the current query, and they are pre-sorted for display. Echo `value` back
  verbatim in a filter.

Keep a selected option visible even when it disappears from the options list,
otherwise the shopper is left with an active filter and no control to clear it.

Because the products you derive options from are already scoped by the filters
you sent, a facet built naively narrows its own option list — selecting one
value erases the alternatives. See
[Filters and suggestions](/shopify-search/custom-search-ui/filters-and-suggestions)
for that pattern and the rest of the client-side behaviour a filter UI needs.

### Errors

| Status | Meaning                                                                         |
| ------ | ------------------------------------------------------------------------------- |
| `400`  | A `locale` your account does not support. The response names the supported ones |
| `404`  | Unknown `merchant_id`, or a `country` with no market mapping                    |
| `422`  | Request body failed validation. The response names the offending field          |
| `5xx`  | Server error — retry with backoff                                               |

A failure that happens *after* the response started streaming arrives in-band:

```
error:{"error_hint":"..."}
```

The search failed; render an error state. Note that the Depict modal does
**not** retry on this line — it retries request-level failures only — because
the server has already reached a verdict on this query. `error_hint` is a
diagnostic string, not a message to show shoppers.

## GET /autocomplete\_v2

Up to three query suggestions for a partial query.

| Query parameter | Type   | Required |
| --------------- | ------ | -------- |
| `query`         | string | yes      |
| `merchant_id`   | string | yes      |
| `locale`        | string | yes      |
| `country`       | string | yes      |

```json theme={null}
{ "suggestions": ["loaf**ers**", "loaf**ers in dark brown suede**"] }
```

Each suggestion is markdown, with `**bold**` marking the completion beyond what
the shopper typed. Render the markdown, but strip the markers to get the query
to search — the plain text is what the suggestion was validated against. See
[Filters and suggestions](/shopify-search/custom-search-ui/filters-and-suggestions#rendering-query-suggestions)
for a parser and the whitespace pitfall in the rendered markup.
Suggestions are validated against the catalogue before being served, so the list
can legitimately be empty.

Responses are CDN-cached with a `Cache-Control` of up to seven days, which means
a response for an older keystroke can arrive after a newer one. Record which
query each response was for and discard stale results.

## GET /empty-state-products

Products, starter suggestions and translated interface strings for the state
before the shopper types.

| Query parameter | Type   | Required |
| --------------- | ------ | -------- |
| `merchant_id`   | string | yes      |
| `locale`        | string | yes      |
| `country`       | string | yes      |

<ResponseField name="products" type="Product[]">
  Same product shape as search results.
</ResponseField>

<ResponseField name="total_count" type="integer">
  Number of products in `products`.
</ResponseField>

<ResponseField name="suggestions" type="string[] | null">
  Starter queries to offer the shopper. Plain text, no markdown.
</ResponseField>

<ResponseField name="localized_strings" type="object">
  46 translated interface strings for `locale`, including per-account overrides.
</ResponseField>

`localized_strings` covers the whole surface of a search UI, so a custom
interface can stay translated without maintaining its own copies:

* **Actions and chrome:** `close`, `restart`, `view_all`, `show_more`,
  `show_less`, `clear_all`, `submit_search`, `open_filters`, `filters`,
  `input_placeholder`, `greeting`, `suggestions_prompt`, `top_picks`,
  `products`, `loading_more`, `no_image`
* **Result states:** `no_results`, `close_matches_title`, `keyword_results`,
  `error_loading_results`, `out_of_stock`, `content_pages_label`
* **Sorting and layout:** `sort_relevance`, `sort_price_asc`, `sort_price_desc`,
  `sort_newest`, `sort_name_asc`, `sort_name_desc`, `grid_1_column`, `grid_2_columns`
* **Filters:** `price`, `filter_min`, `filter_max`, `show_only`, and one label
  per facet — `facet_label_colors`, `facet_label_pattern`,
  `facet_label_material`, `facet_label_occasions`, `facet_label_gender`,
  `facet_label_product_type`, `facet_label_style_attributes`,
  `facet_label_tags`, `facet_label_size`, `facet_label_on_sale`,
  `facet_label_new`, `facet_label_bestseller`

The response carries a `Cache-Control` header — respect it and fetch this once
per page load, not per interaction.

## POST /check\_results\_exist

Answers "would this query return anything?" without running a full search. Takes
the **same request body** as `/search_v2` and returns:

```json theme={null}
{ "has_results": true }
```

The Depict modal uses it to filter suggested follow-up queries down to the ones
that actually lead somewhere, so a shopper is never offered a dead end. It is
substantially cheaper than a search, but it is still a real query — do not call
it on every keystroke.

## Next

<Card title="Tracking events" icon="chart-line" href="/shopify-search/custom-search-ui/tracking">
  The client-side events a custom UI must send for analytics and attribution.
</Card>
