--- title: Browser quickstart description: Go from a publishable key to a working search box — token exchange, a search request, and the events that make ranking improve. slug: start-here/browser-quickstart docKind: tutorial hub: luigisbox-ai --- A working search box in four steps. Everything here runs in the browser; nothing needs a secret. :::tip[There is a shorter path] This page shows the API directly, for cases where discovery results are rendered by your own components. If you are building a storefront integration from scratch, a [distribution repository](/distribution/overview/) gives you the scaffold, the widgets and the analytics wiring already assembled, and a coding agent that knows how to fit them to your markup. → [Your first integration](/distribution/quickstart/) ::: ## What you need | | Looks like | From | |---|---|---| | A catalog with content indexed | `lbc_8w3k2p` | See below | | A publishable key | `pub_live_eu1_CNA1QBVS7PQzKNDn` | Luigi's Box, bound to your channel and origins | | Your channel ID | `lbn_4hj9tv` | The key is bound to it — or read [`/me/entitlements`](/platform/account-structure/#what-can-i-see) | | Your surface ID | `lbs_search_main` | Configured with Luigi's Box | | Your region | `eu1` | It is in your key | :::caution[Index first] Search cannot return what was never indexed. Before wiring any UI, make sure your catalog has content in it and at least one feed run has succeeded. Get content in with a [feed](/indexing/feeds/) or the [Content API](/indexing/content-api/), then confirm it landed: check the [feed's run history](/indexing/feed-management-api/#run-history) and list the catalog's [object types and attributes](/indexing/catalog-metadata/). One `curl` against [`/discovery/v1/search`](/discovery/search/) returning real hits is the signal to start. ::: Register every origin the storefront is served from, staging hostnames included — a token request from an unregistered origin is rejected. ## 1. Set up your constants ```js 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'; ``` ## 2. Identify the browser Discovery requires a visitor identifier, and it has to be stable — it is how personalization and analytics recognize a returning shopper. ```js 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. ## 3. Get a token 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](/api-basics/bot-control/) explains that and the layers around it, including why server-side code must not use this flow. ```js 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: ```js 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. :::tip Call `getToken()` once while the page loads. The only place this flow is visible to a shopper is a cold challenge on the first keystroke. ::: ## 4. Search ```js 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: ```js 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: ```bash curl -X POST 'https://api.eu1.luigisbox.ai/discovery/v1/search?channel_id=lbn_4hj9tv&surface_id=lbs_search_main' \ -H 'Authorization: Bearer ' \ -H 'X-Lbx-Visitor-Id: test-visitor-1' \ -H 'Content-Type: application/json' \ -d '{ "type": "product", "query": "blue shirt" }' ``` ## 5. Report what the shopper did 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. ```js 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](/analytics/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](/start-here/backend-quickstart/#5-report-purchases-server-side). ## Autocomplete Same surface, smaller `size`, one request per type you want to show: ```js 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. ## Next | Add | Page | |---|---| | A filter sidebar | [Facets](/discovery/facets/) | | Category and brand pages | [Collections](/discovery/collections/) | | Recommendation strips | [Recommendations](/discovery/recommendations/) | | Infinite scroll | [Sorting and pagination](/discovery/sorting-and-pagination/) | | Size and colour swatches | [Product variants](/discovery/variants/) | | Personalized results | [Personalization](/discovery/personalization/) | ## Troubleshooting | Symptom | Cause | |---|---| | `403` on `/browser-token` mentioning origin | The page's origin is not registered for the key | | `403` mentioning the nonce or IP | The challenge was reused, or redeemed from a different address | | `403` on `/search` with a fresh token | The token's `audience` or `channel_id` does not match what you are calling | | `400` on `/events` | Missing `X-Lbx-Visitor-Id`, or the wrong `Content-Type` | | `422` on `/search` | Check the message — usually `filters`, `sort` or `size` | | A `private:` field is missing | Expected. Browser tokens never see them. | | Results look wrong for a market | Check you are using that market's `channel_id` | ## See also - [Browser tokens](/authentication/browser-tokens/) — the exchange in detail - [Discovery overview](/discovery/overview/) · [Search](/discovery/search/) - [Sending events](/analytics/sending-events/) · [Event reference](/analytics/event-reference/) - [Errors and rate limits](/api-basics/errors-and-rate-limits/)