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.

    Discovery overview

    View source

    Discovery is the read side of Luigi’s Box AI: you ask a configured surface a question and it answers with a ranked list of catalog objects. Four endpoints cover the whole surface area.

    EndpointAsk it forThe request supplies
    POST /discovery/v1/searchResults for a queryA search query
    POST /discovery/v1/collectionA listing pageA collection, such as a category
    POST /discovery/v1/recommenderRecommendationsAnchor objects, or nothing
    POST /discovery/v1/facetsFilters for a result setThe request_key of a previous result set

    Two more endpoints support the first four: object lookup fetches objects by reference without ranking them, and variant resolution expands a product into its group.

    Every discovery endpoint is a POST that carries its inputs as a JSON body. Filters and sort are structured JSON objects.

    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", "touchpoint": "serp", "query": "running shoes", "size": 24 }'

    Four inputs say where the request is being served. The first three are on nearly every call; touchpoint is optional:

    InputWhereWhat it names
    channel_idQuery parameterThe serving destination — which storefront or market this is
    surface_idQuery parameterThe configured discovery unit answering the request
    typeBody fieldWhich kind of object you want back: product, category, brand, … On a collection request the collection_id names it instead
    touchpointBody fieldWhere in the shopper’s journey the results appear: serp, plp, basket, … Optional, but send it on every request to /search, /collection and /recommender; each accepts only the touchpoints its intent serves

    channel_id and surface_id are query parameters; everything the surface is asked goes in the JSON body. channel_id names the resource the request is authorized on, so leaving it out is a 400 rather than a 422.

    The surface’s intent has to match the endpoint. lbs_search_main belongs to /search; sending it to /recommender is rejected. See Surface identifiers.

    On search and recommendation requests type is required and singular; a collection request takes no type, since its collection_id names the member type. An autocomplete panel showing products, categories and suggested phrases issues three requests, one per type. Each type gets its own ranking and its own result count.

    FieldDefaultPurpose
    filtersnoneRestrict results — see Filters
    return_fieldssurface defaultChoose which fields come back — see Selecting fields
    size10How many results. Max 100.
    user_idanonymousThe signed-in shopper — see Personalization
    personalizetrueWhether to personalize this request

    An unrecognised body field is a 422.

    /search and /collection additionally take sort and cursor; see Sorting and pagination.

    HeaderRequiredPurpose
    Authorization: Bearer <token>YesA token for the …/discovery audience
    Content-Type: application/jsonYesEvery discovery endpoint takes a JSON body
    X-Lbx-Visitor-IdYes, except on /facetsIdentifies the browser
    User-Agent, RefererOptionalImprove traffic classification and reporting

    A request without X-Lbx-Visitor-Id is rejected, except on /facets, which re-reads a result set the visitor was already identified for. Generate the identifier once per browser and keep it stable. See Identifying the shopper.

    {
    "hits": [
    {
    "@id": "product/sku-1001",
    "@type": "product",
    "@title": "Blue Cotton T-Shirt",
    "price": 29.9,
    "url": "https://example.com/products/blue-cotton-t-shirt"
    }
    ],
    "total": 412,
    "total_approx": 412,
    "request_key": "3ab549eb64a20a0c",
    "guid": "3ab549eb64a20a0c",
    "next_page_cursor": "eyJvIjoyNCwic…",
    "pagination_status": "more"
    }

    That is the shape /search and /collection answer with. A recommender answers with hits alone — one fixed-size slate has no counts, no request_key and no cursor. See Recommendations.

    A hit is a flat JSON object. @id and @type are always present; everything else is the catalog fields the surface returns, under the names you indexed them with. There is no attributes wrapper and no per-field envelope:

    hits.map((hit) => ({ id: hit['@id'], title: hit['@title'], price: hit.price }));

    Which fields appear depends on the surface’s configuration, your return_fields, and your credentials — a browser token never sees private: fields. See Selecting fields.

    A hit a paid campaign pinned or boosted carries a @disclosure object. A bury from the same campaign discloses nothing, because it promotes nothing. It is a response-only field: it is never part of your catalog, and it appears only on the products the campaign’s pins and boosts actually placed.

    {
    "@id": "product/sku-1001",
    "@type": "product",
    "@title": "Blue Cotton T-Shirt",
    "@disclosure": {
    "type": "sponsored",
    "advertiser": "Northwear Ltd",
    "payer": "Northwear Group",
    "recipient_parameters": ["search_query"]
    }
    }
    FieldMeaning
    typesponsored — the placement was paid for.
    advertiserOn whose behalf the placement is shown, as the campaign wrote it. Absent when the campaign names no one, and on an ad platform’s placement.
    payerWho paid for it. Absent when the campaign names no payer.
    recipient_parametersWhat decided that this shopper sees the placement on this request. Present on a campaign’s placement.
    providerThe ad platform that sold the placement. Absent for a campaign’s own disclosure.
    trackingImpression and click URLs, under the ad platform’s own key names. Absent otherwise.

    Those six are the whole object; it carries no other keys. Render advertiser and payer as text, not as markup.

    recipient_parameters holds codes, which you word in the shopper’s language:

    CodeThe placement is shown because of
    search_querywhat the shopper searched for
    collectionthe collection the shopper is browsing
    anchorthe products the recommendation is shown for
    nonenothing about the shopper; never combined with another code

    More codes may be added; word one you do not recognise generically rather than failing on it.

    If your site is an online platform under the EU Digital Services Act, Article 26 requires it to show, with each ad, that it is an ad, on whose behalf it is shown, who paid for it when that is someone else, and the main parameters that decided this shopper was shown it. A campaign’s @disclosure carries each of these when the campaign names its advertiser; an ad platform’s placement carries only type, provider and tracking. The wording around them is yours.

    Render a label whenever @disclosure is present. The field is absent — not null — on every other hit, so a presence check is enough.

    It appears only on /search, /collection and /recommender, the endpoints that rank. The direct lookups — bulk get, objects and variant siblings — return what your catalog holds without consulting any campaign, so no hit of theirs is ever disclosed.

    Where it comes from depends on the placement: a business-rules campaign declares it once and every product its rules pin or boost carries it, and an ad platform sets it on the placements it returns, adding its own provider and tracking keys. A product claimed by both is disclosed once, by the ad platform.

    /search and /collection count the result set two ways.

    FieldMeaning
    totalThe exact count. null when the result set is too large to count exactly.
    total_approxAlways present. Use it when total is null.

    For a result count in your UI, read total ?? total_approx. If the surface collapses variants, the count is of variant groups, not individual products — five sizes of one shirt count once. Calling /facets on the result set upgrades total to an exact figure.

    /search and /collection carry a request_key identifying that result set: a hash of the request parameters that decide which objects are in it, so the same request always yields the same value. A personalized request without an explicit sort is ranked for one shopper, so its key also depends on the shopper: two visitors sending it get different values. Every other request keys the same for everyone. Pass it to /facets to compute filters over the same results, and to /variants when expanding groups from that page. It is opaque — do not parse it.

    The same value is also returned as guid. That name is deprecated: read request_key instead, and send request_key to /facets and /variants. guid keeps carrying the request key for now and will later change meaning to a per-request identifier, so code that still reads it will break at that point.

    /recommender carries neither. Its slate is not cached, so there is no result set for /facets or /variants to address.

    1. POST /search with the query → hits, total, request_key.
    2. POST /facets with that request_key → the filter sidebar.
    3. Shopper picks a filter → POST /search again with filters, producing a new request_key.
    4. Shopper scrolls → POST /search again with cursor from next_page_cursor.
    5. Report what was shown and clicked with analytics events.

    A surface learns from the interactions you report; without them its ranking does not improve.