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.

    Search

    View source

    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.

    Terminal window
    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 parameterRequiredNotes
    channel_idYesServing destination
    surface_idYesMust be a search surface
    Body fieldRequiredDefaultNotes
    typeYesproduct, category, brand, article, query, …
    queryNo""The shopper’s input. Up to 256 characters.
    sizeNo101–100
    filtersNononeFilter tree
    sortNorelevance{ "field": …, "direction": "asc" | "desc" } — see sorting
    cursorNofirst pageFrom a previous next_page_cursor. Omit it for the first page; an empty string is a 422.
    return_fieldsNosurface defaultField projection
    user_idNoanonymousSigned-in shopper
    personalizeNotruePersonalize 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.

    {
    "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"
    }
    FieldMeaning
    hitsThe ranked results, best first. Flat objects; @id and @type always present.
    totalExact count, or null when the result set is too large to count exactly
    total_approxAlways present; the fallback when total is null
    guidIdentifies this result set — pass it to /facets
    next_page_cursorPass back as cursor for the next page, or null
    pagination_statusWhy there is or is not a cursor — see pagination

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

    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.

    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.

    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.