Skip to main content
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.
Base URL for every endpoint on this page:
The read endpoints take no credentials. Every request carries merchant_id, locale and country. See the 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:
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.
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:
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 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

string
required
The shopper’s search query.
string
required
Your merchant id.
string
required
Storefront language, for example en.
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.
string
required
One id per browser tab, stable across searches. Generate a random id and keep it in sessionStorage.
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.
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.
integer
default:"30"
Products per page, 1250. 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.
Filter[]
Filters to apply. See Filters. Omit the field entirely when there are none.
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.
string
Stable per-browser id, kept in localStorage. Lets analytics count returning visitors instead of counting every session as a new one.
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.
SearchHistoryData[]
Prior turns of a conversational search, each { query, chat_text }. Only relevant when the conversational answer layer is enabled for your account.
string
The search_id of the previous turn, for conversational analytics.

Response format

The body is a sequence of lines, each key, :, then a JSON fragment, terminated by \n:
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 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

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.

Product object

Every product in keyword, and in the empty-state response, has this shape.
string
required
Product id. This is the id to use in tracking events.
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.
string
required
Product title.
number
required
Current price in the market’s currency.
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.
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.
string | null
Shopify handle, for building the product URL.
string | null
Variant id, when the result represents a specific variant.
string | null
Variant title, for example a size or colour name.
number | null
Pre-discount price. Show a strikethrough when it differs from price.
boolean | null
Whether the product is discounted in this market.
boolean
default:"false"
Whether the product is unavailable in this market.
boolean
default:"false"
Bestseller flag.
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.
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.
number | null
Unix timestamp in seconds. The Depict modal treats a product as “new” when this is within the last 30 days.
string[]
default:"[]"
Colour values. Also a filterable field.
string
default:""
Pattern value.
string
default:""
Material value.
string[]
default:"[]"
Occasion values.
string
default:""
Gender value.
string[]
default:"[]"
Tag values.
string[]
default:"[]"
Style attribute values.
object | null
Always null in normal responses. Ignore it.

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.
integer
Echo of the requested page.
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.
boolean
page > 1.
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.
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.

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 above.
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
The page is full, so has_next_page is true. Request page 2. Request 2 — the same body with "page": 2
Full again. Request page 3. Request 3 — the same body with "page": 3
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?:

Filters

Send filters as an array on the search request. Each entry is { field, operator, value }.
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.
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.
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.
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.

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:
    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 for that pattern and the rest of the client-side behaviour a filter UI needs.

Errors

A failure that happens after the response started streaming arrives in-band:
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.
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 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.
Product[]
Same product shape as search results.
integer
Number of products in products.
string[] | null
Starter queries to offer the shopper. Plain text, no markdown.
object
46 translated interface strings for locale, including per-account overrides.
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:
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

Tracking events

The client-side events a custom UI must send for analytics and attribution.