Pre-launch API. The concepts described here are settled, but the API shape is not: request and response fields, parameters and defaults can still change. Build against it, and talk to your Luigi's Box contact before you put an integration into production.
POST /discovery/v1/search answers a shopper’s query. It serves both the as-you-type panel
and the results page, usually from the same surface.
Request
Section titled “Request”curl -X POST 'https://api.eu1.luigisbox.ai/discovery/v1/search?channel_id=lbn_4hj9tv&surface_id=lbs_search_main' \ -H 'Authorization: Bearer <token>' \ -H 'X-Lbx-Visitor-Id: 8f14e45f-ea0f-4b5c-9a1d-2b3c4d5e6f70' \ -H 'Content-Type: application/json' \ -d '{ "type": "product", "query": "blue cotton shirt", "size": 24, "filters": { "availability": { "eq": 1 }, "price": { "lt": 50 } }, "sort": { "field": "price", "direction": "asc" } }'channel_id and surface_id are query parameters. Everything else goes in the JSON body.
| Query parameter | Required | Notes |
|---|---|---|
channel_id | Yes | Serving destination |
surface_id | Yes | Must be a search surface |
| Body field | Required | Default | Notes |
|---|---|---|---|
type | Yes | — | product, category, brand, article, query, … |
query | No | "" | The shopper’s input. Up to 256 characters. |
size | No | 10 | 1–100 |
filters | No | none | Filter tree |
sort | No | relevance | { "field": …, "direction": "asc" | "desc" } — see sorting |
cursor | No | first page | From a previous next_page_cursor. Omit it for the first page; an empty string is a 422. |
return_fields | No | surface default | Field projection |
user_id | No | anonymous | Signed-in shopper |
personalize | No | true | Personalize this request |
Unknown body fields are rejected with a 422.
An empty query is valid: the surface answers with whatever it is configured to show for
no input. To browse a category instead, use /collections, which
takes a scope rather than a query.
Response
Section titled “Response”{ "hits": [ { "@id": "product/sku-1001", "@type": "product", "@title": "Blue Cotton T-Shirt", "price": 29.9, "list_price": 39.9, "availability": 1, "image_url": "https://cdn.example.com/products/sku-1001.jpg", "url": "https://example.com/products/blue-cotton-t-shirt" } ], "total": 412, "total_approx": 412, "guid": "3ab549eb64a20a0c", "next_page_cursor": "eyJvIjoyNCwic…", "pagination_status": "more"}| Field | Meaning |
|---|---|
hits | The ranked results, best first. Flat objects; @id and @type always present. |
total | Exact count, or null when the result set is too large to count exactly |
total_approx | Always present; the fallback when total is null |
guid | Identifies this result set — pass it to /facets |
next_page_cursor | Pass back as cursor for the next page, or null |
pagination_status | Why there is or is not a cursor — see pagination |
Autocomplete
Section titled “Autocomplete”Autocomplete is not a separate endpoint. It is the same search surface, called with a
partial query and a small size, typically once per type you want to show:
const url = `${API}/discovery/v1/search?channel_id=${channelId}&surface_id=lbs_search_main`;
const [products, categories, suggestions] = await Promise.all( ['product', 'category', 'query'].map((type) => fetch(url, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ type, query: input.value, size: 6 }), }).then((r) => r.json()), ),);Recommended client behaviour:
- Debounce, do not throttle. Wait about 120 ms after the last keystroke. A request per keystroke spends rate-limit budget on queries that are never shown.
- Abort superseded requests. Keep an
AbortControllerper input and cancel the previous request when a new keystroke arrives, so a slow early response cannot overwrite a fast later one. - Mint the token before the first keystroke. Fetching a token on the first keystroke delays the first response.
When the panel and the results page share a surface, the top autocomplete result is the top result on the results page, and retuning one retunes both.
Filtering as the shopper narrows
Section titled “Filtering as the shopper narrows”Facet selections become a filters tree. Rebuild it from the selected values on each
request rather than accumulating fragments:
function buildFilters({ brands, maxPrice, inStockOnly }) { const clauses = []; if (brands.length) clauses.push({ brand: { in: brands } }); if (maxPrice != null) clauses.push({ price: { lte: maxPrice } }); if (inStockOnly) clauses.push({ availability: { eq: 1 } });
if (clauses.length === 0) return undefined; if (clauses.length === 1) return clauses[0]; return { $and: clauses };}Omit filters entirely when nothing is selected — {} is not a valid filter node. Each
new filter combination is a new result set with a new guid, so re-request facets after
the search returns. See Facets.
Zero results
Section titled “Zero results”An empty hits array with total: 0 is a normal response, not an error. The
no-results reports list the queries your
catalog cannot answer.
A common recovery is to fall back to a recommender surface — bestsellers, or items related to the shopper’s session — so the page still has something to offer.
Reporting what happened
Section titled “Reporting what happened”The slate a surface returned is recorded when the request is served. You report what the
shopper did next: an interaction when they click a result or add it to the cart, and a
transaction when they buy. Each of those events names
the object with a reference, which is what lets Luigi’s Box credit
it to this search. See Sending events.
See also
Section titled “See also”- Filters · Facets
- Sorting and pagination · Selecting fields
- Product variants — collapsing size and colour duplicates
- Personalization
Was this page helpful?
Thanks.