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:
Every request carries 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 by merchant_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:
or as a request header, for a non-browser caller:
Depict’s own storefront script already sends it on the tracking endpoint. Wire it into your search client too if you have been issued one: once the check is enabled, a request without a token answers 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:
Fair use applies. A rate limit answering 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_v2 rather than calling it per keystroke, and cancel the in-flight request when the shopper types again;
  • keep one /search_v2 in flight per search;
  • fetch /empty-state-products once per page load, not per interaction;
  • do not call /check_results_exist on 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:
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. 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.
A field the request model does not know is ignored rather than rejected, so an extra key in your body is harmless.

Response format

The body is a sequence of lines, each key, :, then a JSON fragment, terminated by \n. A real response for coat on a live store:
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, 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.
A 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 in keyword, 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 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
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_page can turn false while 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 limit products”, and the server does not look ahead. So a true here can be followed by an empty page: when the results run out on an exact multiple of limit, the page after the last one is 200 with keyword:[] and has_next_page: false.
Requesting a page past the end is never 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 “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, 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 coat 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
One product, 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 }.
Operators: =, !=, >, <, >=, <=, 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:
A filter your index cannot serve never fails the search. It fails quietly, in one of two ways, and neither is announced:
  • The clause is dropped. When your catalogue’s document contract has no equivalent for the field, no clause is compiled at all and the search runs as though you had not sent that filter — a result set wider than the one you asked for.
  • The clause matches nothing. When the field resolves but no product carries the value, you get 200 with keyword:[], n_hits: null and no facet_counts line at all. That signature — an empty grid with no counts — is the one you will actually see when you send a value that does not exist.
There is no error to catch either way. Stick to the fields in filter_facets, the keys of facet_counts and price, and echo values back verbatim from facet_counts, unless Depict has confirmed another field 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 facet_counts where the backend supplies them, or from the values in the current result set. Do not invent or normalise values.One conversion is required rather than optional: a boolean facet’s options arrive in facet_counts as the strings "true" and "false", and a filter on that field takes a JSON boolean. Convert when you send.
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_counts carries options for this query’s own result pool, already sorted for display (the size ladder for size, 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.
  • size is 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_counts is 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: use facet_counts_selected[f] for a touched group f and facet_counts for every other. facet_counts_selected is 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_attributes are arrays on each product; pattern, material, gender, product_type are 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 problem facet_counts_selected solves server-side.
  • Echo value back verbatim in a filter (converting booleans, as above).
Two things the counts are not:
  • 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 cards n_hits reports. The two therefore disagree in both directions: one live coat response reports n_hits: 55 alongside product_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 carries min and max over the same pool and is page-independent.
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. See Filters and suggestions for that pattern and the rest of the client-side behaviour a filter UI needs.

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:
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. 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
The bundle is a superset of what the API serves, so do not treat it as a feature list. 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.
Send it the body you would send /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 as Content-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.