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 collections 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", "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.

    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 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.

    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.