Skip to content

    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.

    Sorting and pagination

    View source

    Search and collection results are ordered and paged. Recommendations are neither; a recommender returns one fixed-size slate.

    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.

    DirectionMeaning
    ascSmallest or earliest first
    descLargest or latest first

    Both keys are required. Omitting direction, or sending anything but asc / desc, is a 422.

    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.

    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.

    Discovery pages with an opaque cursor, not with page numbers. A cursor keeps page 2 consistent with the page 1 the shopper saw.

    Terminal window
    # First page
    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-…' \
    -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.

    pagination_status says why next_page_cursor is or is not set:

    ValueMeaningWhat to do
    moreMore pages exist, and next_page_cursor is setKeep paging
    exhaustedThat was the last pageStop
    unsupportedThis result set cannot be paged at allStop — retrying will not help
    unavailablePagination state could not be saved this timeRe-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 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.

    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.

    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.