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 collection 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", "touchpoint": "serp", "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", "touchpoint": "serp", "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 collection scope,
the sort, type, or the channel or surface you call, and the old cursor is rejected with a 422
whose exception_details.error_code is cursor_mismatch. A cursor that was truncated or
edited in transit is rejected the same way with cursor_malformed. On either, drop the
cursor and request the first page again. Changing size between pages keeps the cursor valid. In a UI, reset the cursor
whenever a facet is clicked or the sort changes.
A request sent with personalize: true and no explicit sort is ranked for one shopper, so
the shopper counts too: changing personalize, user_id or the X-Lbx-Visitor-Id starts
over, and a different visitor replaying the cursor gets the same 422. A blank user_id and
undefined, null, None, NaN or [object Object] all count as none, so moving between them
keeps the cursor.
Under an explicit sort personalization does not change the order, so a sorted result set and
its cursors are the same for every shopper.
- A backend paging on behalf of many shoppers must forward each shopper’s own visitor ID on every page of their sequence, not one value for the whole service.
- Resetting the visitor ID — on sign-out, for example — invalidates any cursor still in flight. Request the first page again.
With personalize: false, cursors do not depend on the visitor ID.
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 · Collection
- 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.