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:
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.
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:
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 withContent-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,
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.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, eachkey, :, then a JSON fragment,
terminated by \n:
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 inkeyword, 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 separatePOST /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, withpage 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_idis 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
pageis a new search. A new query, an added or removed filter or a different sort produces a different result set: start again atpage: 1with a newsearch_id. The Depict modal mints a fresh id on every filter change for exactly this reason, and resets its page counter with it. limitstays constant within a search, for the reason given underlimitabove.
Worked example: three pages of one search
A search forblack loafers with limit: 15 that matches 33 products takes
three requests. Only page differs between them; the product objects below
are abbreviated.
Request 1
has_next_page is true. Request page 2.
Request 2 — the same body with "page": 2
"page": 3
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 }.
=, !=, >, <, >=, <=, 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.
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.
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_attributesare arrays on each product;pattern,material,gender,product_typeare single values. -
sizeis 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 infacet_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. Echovalueback verbatim in a filter.
Errors
A failure that happens after the response started streaming arrives in-band:
error_hint is a
diagnostic string, not a message to show shoppers.
GET /autocomplete_v2
Up to three query suggestions for a partial query.**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
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:
Next
Tracking events
The client-side events a custom UI must send for analytics and attribution.

