merchant_id, locale and country. See the
quickstart for where those
values come from.
Credentials, CORS and fair use
Requests do not currently require a credential. The store is identified bymerchant_id, and the token check on /search_v2, /autocomplete_v2,
/empty-state-products and /check_results_exist has not been switched on yet.
It is coming, and the token already exists.
Include the publishable token now, so your integration keeps working when
enforcement begins. Depict issues a per-shop depict_token: a non-secret
value, derived from your merchant id, that identifies and scopes the store the
same way a Stripe or Algolia publishable key does. It is safe in browser
JavaScript by design. Send it either as a query parameter, which keeps a GET
free of a CORS preflight:
401 and a request with a wrong one
403.
CORS. The API answers Access-Control-Allow-Origin: * and does not
accept credentials, so send no cookies and no Authorization header. A
preflight is answered with:
429 with a Retry-After header —
saying how many seconds until the window resets — is planned, per merchant and
per IP. Every call costs a query embedding and a retrieval, so build the client
to be economical from the start:
- debounce
/autocomplete_v2rather than calling it per keystroke, and cancel the in-flight request when the shopper types again; - keep one
/search_v2in flight per search; - fetch
/empty-state-productsonce per page load, not per interaction; - do not call
/check_results_existon every keystroke.
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 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 or content 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, and carries Cache-Control: no-cache.
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. A locale your catalogue has no fields
for returns 400, and the response names the ones it does have:
{"detail":"Unsupported locale 'zz' for merchant YOUR_MERCHANT_ID; supported locales: ['en', 'fr', 'ja', 'ko']"}. That list is per store — read it from
the error rather than assuming a set.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. A country
with no market mapping 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. The ceiling is the search index’s own per-page ceiling, not a
product decision. Keep limit 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[] | null
Filters to apply. See Filters. Omit the field, or send
null —
both mean “no filters”.SortModel
Server-side ordering:
{ "field": "created", "order": "desc" }. Three fields
are offered — _relevance (desc only), price (asc or desc) and
created (desc only) — and the combinations your store actually serves are
advertised in the response’s sorts: line. Read them from there
(meta.values lists the allowed orders per field) rather than hardcoding: an
unoffered field or order returns 422 naming what is offered, and 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. A real response for coat on a live store:
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, and skip a line with no colon. New keys are
added to this stream additively, and a client that ignores unknown keys keeps
working.
Stream keys
Keys arrive in the order below.In the default configuration a response contains
filter_facets, sorts,
facet_counts, keyword and keyword_page_info, in that order, plus
facet_price_stats where a price facet is configured. filter_facets and
sorts are on every response — they are emitted before retrieval runs,
so they are present even when the search fails and an error line follows.
The conversational and content keys are per-account features; the content
keys arrive after keyword_page_info, so a client that stops reading at
the pagination line never sees them. Check what your account returns before
building UI that depends on them.qu_debug line exists but is served only to Depict’s verified-internal
callers; it never appears on a storefront’s wire.
Product object
Every product inkeyword, and in the empty-state response, has this shape.
The field set is an allowlist rather than a dump of an internal model, so the
24 fields below are the whole object.
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 | null
Variant id. Non-
null only on a catalogue grouped by colour, where the card
stands for one colourway. null otherwise, including when
variant_title is set.string
required
Product title. On a catalogue grouped by colour the served colour name is
appended to it, so the title already reads as the colourway.
string | null
Variant title, for example a size or colour name. Present independently of
variant_id — a card can carry a size here with variant_id: null.number
required
Current price in the market’s currency.
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"
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 | null
Shopify handle, for building the product URL.
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. Never null.string[]
required
Image URLs, primary image first. At most two — the API truncates the
list, so do not build a gallery expecting every image. On a catalogue grouped
by colour it is the one image of the served colourway. Can be empty.
boolean
default:"false"
Whether the product is unavailable in this market.
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
Whether another page can be served. This is the only field to drive
fetching from. How it is computed depends on your store’s configuration,
and both arms matter:
- Where the engine knows the size of the candidate pool it retrieved, it
answers “the pool extends past this window”. That pool is bounded by how
deep the engine fetched, not by the index, so
has_next_pagecan turnfalsewhile more matches exist server-side — an honest end of the scroll rather than a page that comes back empty. - Otherwise it means “this page came back full, with
limitproducts”, and the server does not look ahead. So atruehere can be followed by an empty page: when the results run out on an exact multiple oflimit, the page after the last one is200withkeyword:[]andhas_next_page: false.
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 “55 results” label. Counted in product cards, not in
variant documents, and never smaller than the page it just served.It is not guaranteed exact: the underlying grouped count is
order-dependent at the margin, so the same filter can report a couple of
results more under
price:asc than under price:desc. Never compute
pagination from it — n_hits ÷ limit disagrees with has_next_page in both
directions.null when the served route has no honest total. That covers more than
fallback results: a query or filter combination that matched nothing, and a
relaxed fallback (is_fallback: true), both answer n_hits: null. Treat
null as “no total to show”, not as zero — a page past the end still
reports the real total.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. A
zero-result page is not a fallback — is_fallback stays false and
keyword is [].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: two pages of one filtered search
A search forcoat on a live store, filtered to colors: black and
size: S, with limit: 3. It matches 4 products, so it takes two requests.
Only page differs between them; the metadata and product objects below are
abbreviated.
Request 1
has_next_page is true. Request page 2.
Request 2 — the same body with "page": 2
has_next_page: false, and the client stops. Note what a
has_next_page client gets right that an n_hits client does not: on this
same store the query what is your return policy at limit: 3 reports
n_hits: 3 on every page, while has_next_page stays true and pages 2, 3
and 4 each serve three more products. A client that paged from n_hits would
have stopped after the first page. Requesting page 99 of coat is also 200,
with keyword:[], has_next_page: false and n_hits still reported.
The metadata lines (filter_facets, sorts, facet_counts,
facet_price_stats) repeat on every page and are page-independent; 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. Anything else is
a 422 that lists these eight.
Fields. The facets a store can have enabled are colors, material,
gender, product_type, on_sale, is_bestseller, new and size; the
first four are the defaults. Whichever of those your account has enabled are
the ones listed in filter_facets. price is filterable too, and has its own
facet_price_stats key rather than a facet entry.
The request also accepts a wider set of field names that exist for other
catalogue shapes — brand, kind, room, shape, category_paths and
others. A name outside the accepted set is a 422 whose message enumerates
every name the API takes, so you never have to guess. A name inside the set
that your index does not carry is a different story:
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.
Price is another place where the response and the request do not use the same
name. facet_price_stats.field is the index’s own market-specific price column
(price__99922444666); send price filters on the field price.
Building facet options
filter_facets tells you which filters to show; facet_counts and
facet_counts_selected tell you what to put in them.
-
Prefer the server’s counts over your own derivation.
facet_countscarries options for this query’s own result pool, already sorted for display (the size ladder forsize, count descending elsewhere). A value that would return no products is simply absent — that absence is how you drop a dead option, and it is not something you can reproduce from the loaded cards. -
sizeis the case you cannot do without it. Sizes live on variants and results are grouped by product, so they are not on the product cards at all. -
For a group the shopper has already filtered, read
facet_counts_selected[field]instead —facet_countsis narrowed by the selection, so rendering that group from it erases the alternatives and leaves the shopper unable to change their mind. Never intersect the two: usefacet_counts_selected[f]for a touched groupfandfacet_countsfor every other.facet_counts_selectedis absent when no filter was sent, which is most requests. -
Where the backend counts nothing for a field, 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. Because those products are already scoped by the filters you sent, a facet built this way narrows its own option list — selecting one value erases the alternatives, the problemfacet_counts_selectedsolves server-side. -
Echo
valueback verbatim in a filter (converting booleans, as above).
- Not comparable to
n_hits. The counts are taken over the pool the query retrieved, at most 250 products deep, and at a different grain from the grouped cardsn_hitsreports. The two therefore disagree in both directions: one livecoatresponse reportsn_hits: 55alongsideproduct_type: [{"value":"coat","count":294}]. Do not display a count as “of N results”, and do not sanity-check one against the other. - Not a price range. For a price slider use
facet_price_stats, which carriesminandmaxover the same pool and is page-independent.
Query rules
Coming soon. Query rules — per-query merchandising a merchant configures
in the Depict app rather than in your client — are in development, and this
section will be filled in when the feature ships.
Errors
A request-level error body is a JSON object with a
detail key. For 422 that
detail is an array:
401, 403 and 429 belong to the token, origin and quota controls described
under Credentials, CORS and fair use. Handle
them in your client: 401 and 403 mean the token check applies to your
requests, and 429 means you are over quota — read Retry-After and back off.
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. The
markers are always balanced, so a plain replaceAll("**", "") is safe. 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 cached at two levels, and the header says both:
max-age is always 10 minutes for the browser. s-maxage is the CDN’s, and it
varies with how settled the answer is — 5 minutes while suggestions for that
prefix are still being generated, an hour for a thin or freshly edited slate, a
day when some candidates were dropped, and up to seven days for a fully
validated one. The practical consequence is the same either way: a response for
an older keystroke can arrive after a newer one, so record which query each
response was for and discard stale results.
A configuration problem answers 404. A failure in the suggestion service
answers 500 with
{"detail":"Autocomplete service temporarily unavailable"}; an empty list is
not an error.
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 — unlike
/autocomplete_v2, do not strip anything from these.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
sort_name_asc and sort_name_desc are the clear case: the
search API offers only _relevance, price and created, so building a sort
control from these labels would offer an option that returns 422. Drive the
control from the sorts: stream key and use the bundle only for its wording.
The response carries a Cache-Control header (public, max-age=…, the TTL
chosen per store) — respect it and fetch this once per page load, not per
interaction. An unknown merchant_id answers 404.
POST /check_results_exist
Answers “would this query return anything?” without running a full search./search_v2 — it is accepted without complaint,
so you can reuse the object you already built. But only query, merchant_id,
locale, country and history reach the search — session_id and
search_id are carried for logging only, filters, sort and page are
validated and then ignored, and limit is not on this endpoint’s model at all.
A filtered check_results_exist therefore answers about the unfiltered
query. Do not use it to test whether a filter combination has results; run the
search.
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. An unknown merchant_id answers 404.
POST /events
The fifth endpoint on this host is the analytics beacon. It takes a batch of events asContent-Type: text/plain (a CORS simple request, so no preflight on
page unload) and answers 202 with {"accepted": n}. Its event taxonomy,
required fields and batch limits are in
Tracking events.
Next
Tracking events
The client-side events a custom UI must send for analytics and attribution.

