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.
Search and collections results are ordered and paged. Recommendations are neither; a recommender returns one fixed-size slate.
Sorting
Section titled “Sorting”One body field, an object with a field and a direction:
{ "sort": { "field": "price", "direction": "asc" } }{ "sort": { "field": "created_at", "direction": "desc" } }{ "sort": { "field": "lbx:price", "direction": "asc" } }There is no multi-field sort. A namespaced field such as lbx:price is written as-is.
| Direction | Meaning |
|---|---|
asc | Smallest or earliest first |
desc | Largest or latest first |
Both keys are required. Omitting direction, or sending anything but asc / desc, is a
422.
Relevance is the default
Section titled “Relevance is the default”With no sort, results come back in the surface’s own ranked order — relevance for a
query, the surface’s configured ordering for a collection. Use it as the default option in
a sort dropdown:
const SORT_OPTIONS = [ { label: 'Relevance', value: undefined }, // omit the field entirely { label: 'Price, low to high', value: { field: 'price', direction: 'asc' } }, { label: 'Price, high to low', value: { field: 'price', direction: 'desc' } }, { label: 'Newest', value: { field: 'created_at', direction: 'desc' } },];Omit sort for relevance rather than sending null.
An explicit sort replaces ranking rather than refining it: the sort field is the only
ordering applied.
A field is sortable if you can see it
Section titled “A field is sortable if you can see it”If a field is not returned on a hit, do not sort by it. Sorting on a text field sorts
lexically, which is why price stored as "9.90" sorts before "10.00" — check the field’s
type with catalog metadata if an ordering looks wrong.
Pagination
Section titled “Pagination”Discovery pages with an opaque cursor, not with page numbers. A cursor keeps page 2 consistent with the page 1 the shopper saw.
# First pagecurl -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-…' \ -H 'Content-Type: application/json' \ -d '{ "type": "product", "query": "running shoes", "size": 24 }'The response carries the cursor for the page after it:
{ "hits": [ "…24 hits…" ], "total": 412, "next_page_cursor": "eyJvIjoyNCwic…", "pagination_status": "more"}Pass it back verbatim as cursor, with every other field unchanged:
{ "type": "product", "query": "running shoes", "size": 24, "cursor": "eyJvIjoyNCwic…" }Cursors are opaque. Do not decode, edit or construct one. For the first page, leave
cursor out (or send null) — an empty string is a 422.
Reading pagination_status
Section titled “Reading pagination_status”pagination_status says why next_page_cursor is or is not set:
| Value | Meaning | What to do |
|---|---|---|
more | More pages exist, and next_page_cursor is set | Keep paging |
exhausted | That was the last page | Stop |
unsupported | This result set cannot be paged at all | Stop — retrying will not help |
unavailable | Pagination state could not be saved this time | Re-issuing the search may succeed |
Branch on pagination_status, not on the cursor’s presence:
async function* pages(body) { const url = `${API}/discovery/v1/search?channel_id=${channelId}&surface_id=${surfaceId}`; let cursor;
for (;;) { const page = await fetch(url, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, ...(cursor && { cursor }) }), }).then((r) => r.json()); yield page;
if (page.pagination_status !== 'more') return; cursor = page.next_page_cursor; }}A short page is not the last page
Section titled “A short page is not the last page”A page may return fewer than size hits and still carry a cursor. Results are
re-validated after retrieval, and anything that went stale between indexing and serving is
dropped — so a page of 24 can arrive with 22 hits.
Keep paging until pagination_status stops being more. Treating a short page as the end
silently truncates results.
Changing anything starts over
Section titled “Changing anything starts over”A cursor belongs to one result set. Change the query, the filters, the sort or the size and
the old cursor is rejected with a 422 — drop it and request the first page again. In a
UI, reset the cursor whenever a facet is clicked or the sort changes.
Order counts as a change. A cursor is bound to the filter tree exactly as you wrote it, so
reordering the entries of an $and array, the fields inside one condition node, or the
values inside an in list all produce a different result set as far as pagination is
concerned. Build the tree the same way on every page of a sequence — the
buildFilters pattern, run
over the same selections, does this.
Totals and infinite scroll
Section titled “Totals and infinite scroll”For “412 results”, read total ?? total_approx:
const count = page.total ?? page.total_approx;total is exact but can be null when the result set is too large to count exactly;
total_approx is always present. Calling /facets upgrades total
to an exact figure, so a page that renders a filter sidebar usually has a real number.
For infinite scroll, drive the loop off pagination_status and use the total only as a
label. If the surface collapses variants, the total counts variant groups — five sizes of
one shirt count once.
See also
Section titled “See also”- Search · Collections
- Facets — refreshing the sidebar on each new result set
- Product variants — why totals count groups
- Requests and responses — page/size pagination on configuration endpoints
Was this page helpful?
Thanks.