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.
A working search box in four steps. Everything here runs in the browser; nothing needs a secret.
What you need
Section titled “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 |
| Your surface ID | lbs_search_main | Configured with Luigi’s Box |
| Your region | eu1 | It is in your key |
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
Section titled “1. Set up your constants”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
Section titled “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.
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
Section titled “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 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.
4. Search
Section titled “4. Search”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:
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" }'5. Report what the shopper did
Section titled “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.
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.
Autocomplete
Section titled “Autocomplete”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.
| Add | Page |
|---|---|
| A filter sidebar | Facets |
| Category and brand pages | Collections |
| Recommendation strips | Recommendations |
| Infinite scroll | Sorting and pagination |
| Size and colour swatches | Product variants |
| Personalized results | Personalization |
Troubleshooting
Section titled “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
Section titled “See also”- Browser tokens — the exchange in detail
- Discovery overview · Search
- Sending events · Event reference
- Errors and rate limits
Was this page helpful?
Thanks.