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.

    Browser quickstart

    View source

    A working search box in four steps. Everything here runs in the browser; nothing needs a secret.

    Looks likeFrom
    A catalog with content indexedlbc_8w3k2pSee below
    A publishable keypub_live_eu1_CNA1QBVS7PQzKNDnLuigi’s Box, bound to your channel and origins
    Your channel IDlbn_4hj9tvThe key is bound to it — or read /me/entitlements
    Your surface IDlbs_search_mainConfigured with Luigi’s Box
    Your regioneu1It is in your key

    Register every origin the storefront is served from, staging hostnames included — a token request from an unregistered origin is rejected.

    const REGION = 'eu1';
    const AUTH_HOST = `https://auth.${REGION}.luigisbox.ai`;
    const API_HOST = `https://api.${REGION}.luigisbox.ai`;
    const API_KEY = 'pub_live_eu1_CNA1QBVS7PQzKNDn';
    const CHANNEL_ID = 'lbn_4hj9tv';
    const SURFACE_ID = 'lbs_search_main';

    Discovery requires a visitor identifier, and it has to be stable — it is how personalization and analytics recognize a returning shopper.

    function visitorId() {
    let id = localStorage.getItem('lbx_visitor_id');
    if (!id) {
    id = crypto.randomUUID();
    localStorage.setItem('lbx_visitor_id', id);
    }
    return id;
    }

    It persists across page loads and across token expiry. A visitor ID that changes per page load makes every page load look like a new shopper.

    A publishable key becomes a token by solving a small proof-of-work challenge — a few hundred milliseconds, once, then cached until it expires. It is there to make bulk scraping of your catalog expensive, not to prove identity; Bot control explains that and the layers around it, including why server-side code must not use this flow.

    async function solveChallenge(nonce, difficulty) {
    const encoder = new TextEncoder();
    for (let solution = 0; ; solution += 1) {
    const digest = new Uint8Array(
    await crypto.subtle.digest('SHA-256', encoder.encode(nonce + solution)),
    );
    if (leadingZeroBits(digest) >= difficulty) return solution;
    }
    }
    function leadingZeroBits(bytes) {
    let bits = 0;
    for (const byte of bytes) {
    if (byte === 0) {
    bits += 8;
    continue;
    }
    return bits + Math.clz32(byte) - 24;
    }
    return bits;
    }

    Wrap the exchange so it caches and refreshes itself:

    function createTokenProvider(audience) {
    let cached = null;
    async function mint() {
    const url = `${AUTH_HOST}/browser-challenge?api_key=${encodeURIComponent(API_KEY)}`;
    const { nonce, difficulty } = await fetch(url).then((r) => r.json());
    const solution = await solveChallenge(nonce, difficulty);
    const { access_token, expires_in } = await fetch(`${AUTH_HOST}/browser-token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
    api_key: API_KEY,
    pow_nonce: nonce,
    pow_solution: solution,
    channel_id: CHANNEL_ID,
    audience,
    }),
    }).then((r) => r.json());
    // Refresh 30s early so an in-flight request never races expiry.
    return { token: access_token, expiresAt: Date.now() + (expires_in - 30) * 1000 };
    }
    return async () => {
    if (!cached || Date.now() >= cached.expiresAt) cached = await mint();
    return cached.token;
    };
    }
    // One token covering both services — searching and reporting.
    const getToken = createTokenProvider([`${API_HOST}/discovery`, `${API_HOST}/events`]);

    audience accepts an array, so one token serves discovery and analytics. One challenge, one solve, one cached token for the whole page.

    const SEARCH_URL =
    `${API_HOST}/discovery/v1/search?channel_id=${CHANNEL_ID}&surface_id=${SURFACE_ID}`;
    async function search(query, { size = 24, filters, sort } = {}) {
    const response = await fetch(SEARCH_URL, {
    method: 'POST',
    headers: {
    Authorization: `Bearer ${await getToken()}`,
    'X-Lbx-Visitor-Id': visitorId(),
    'Content-Type': 'application/json',
    },
    body: JSON.stringify({
    type: 'product',
    query,
    size,
    return_fields: ['@title', 'price', 'image_url', 'url', 'availability'],
    ...(filters && { filters }),
    ...(sort && { sort }),
    }),
    });
    if (!response.ok) throw new Error(`Search failed: ${response.status}`);
    return response.json();
    }

    Spread filters and sort in only when you have them — sending null for either is a 422.

    Hits are flat objects — @id, @type, and the fields you asked for:

    const { hits, total } = await search('blue shirt');
    hits.forEach((hit) => {
    console.log(hit['@id'], hit['@title'], hit.price);
    });

    Try it with curl first, before wiring any UI:

    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: test-visitor-1' \
    -H 'Content-Type: application/json' \
    -d '{ "type": "product", "query": "blue shirt" }'

    Ranking learns from behaviour, so a search whose outcome is never reported contributes nothing to it.

    You do not report the results you rendered — those are recorded when the request is served. You report clicks and cart additions, flagging the ones that came from a result set so Luigi’s Box can credit them to it.

    async function sendEvent(event) {
    await fetch(`${API_HOST}/events/v1/events`, {
    method: 'POST',
    headers: {
    Authorization: `Bearer ${await getToken()}`,
    'Content-Type': 'application/json',
    'X-Lbx-Visitor-Id': visitorId(),
    },
    body: JSON.stringify({
    channel_id: CHANNEL_ID,
    consent_granted: hasConsent(),
    ...event,
    }),
    });
    }
    function reference(hit) {
    return `product/@id:${hit['@id']}`;
    }
    function onResultClick(hit) {
    sendEvent({
    type: 'interaction',
    interaction_type: 'click',
    reference: reference(hit),
    listing_ab_test: false,
    });
    }
    function onAddToCart(hit, quantity) {
    sendEvent({
    type: 'interaction',
    interaction_type: 'add_to_cart',
    reference: reference(hit),
    listing_ab_test: false,
    count: quantity,
    price: hit.price,
    });
    }

    The reference is what connects the click back to the search that earned it — Luigi’s Box matches the object within the session against the result sets it recorded. On a results page the @id from the response is the easiest reference to build; on a cart page, where you may not have it, any unique field works — see Object references.

    Purchases work the same way. If you have a server-side order handler, sending them from there is a little more reliable — see Backend quickstart.

    Same surface, smaller size, one request per type you want to show:

    let inFlight;
    async function autocomplete(input) {
    inFlight?.abort();
    inFlight = new AbortController();
    const token = await getToken();
    const headers = {
    Authorization: `Bearer ${token}`,
    'X-Lbx-Visitor-Id': visitorId(),
    'Content-Type': 'application/json',
    };
    const [products, categories, suggestions] = await Promise.all(
    ['product', 'category', 'query'].map((type) =>
    fetch(SEARCH_URL, {
    method: 'POST',
    headers,
    signal: inFlight.signal,
    body: JSON.stringify({ type, query: input, size: 6 }),
    }).then((r) => r.json()),
    ),
    );
    return { products, categories, suggestions };
    }

    Debounce around 120 ms, and abort superseded requests so a slow early response cannot overwrite a fast later one.

    AddPage
    A filter sidebarFacets
    Category and brand pagesCollections
    Recommendation stripsRecommendations
    Infinite scrollSorting and pagination
    Size and colour swatchesProduct variants
    Personalized resultsPersonalization
    SymptomCause
    403 on /browser-token mentioning originThe page’s origin is not registered for the key
    403 mentioning the nonce or IPThe challenge was reused, or redeemed from a different address
    403 on /search with a fresh tokenThe token’s audience or channel_id does not match what you are calling
    400 on /eventsMissing X-Lbx-Visitor-Id, or the wrong Content-Type
    422 on /searchCheck the message — usually filters, sort or size
    A private: field is missingExpected. Browser tokens never see them.
    Results look wrong for a marketCheck you are using that market’s channel_id