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 publishable key is the credential you can put in page source. It is not a secret, and it is not sufficient on its own: the browser exchanges it for a short-lived access token by solving a proof-of-work challenge. The challenge is one of several bot-control layers in front of the API — see Bot control for the rest, and for why a server-side integration must not use this flow.
What a browser token can do
Section titled “What a browser token can do”Exactly two things, on exactly one channel, from an origin you registered:
- Read discovery — search, collections, recommendations, facets, object lookup, variants.
- Write analytics events for that same channel.
A browser token cannot index or delete catalog content, cannot read catalog configuration, cannot touch organizations, catalogs, channels, surfaces or campaigns, and cannot request ranking diagnostics.
The token’s permissions are derived from the key — the channel it is bound to, plus which of the two audiences you asked for. Your account’s own grants are not consulted. Requesting any other audience yields a token with an empty permission set.
What a publishable key is bound to
Section titled “What a publishable key is bound to”| Bound to | Effect |
|---|---|
| One channel | Results and events for that channel only. A request naming a different channel is rejected, even if your account owns it. |
| A list of origins | A token request from an unregistered origin is rejected, and the issued token stays bound to the origin it was minted for |
| One region and environment | A test key cannot reach production, and a European key cannot reach the US region |
The key looks like pub_live_eu1_CNA1QBVS7PQzKNDn: environment, region, then random
characters. Ask Luigi’s Box for a key per storefront, and register every origin the
storefront is served from — including staging hostnames.
Because a token minted from a publishable key is a browser token, it never sees fields in
the private: namespace. Anything you do not want a shopper to be able to read must live
there. See Keeping data out of the browser.
The exchange, in three steps
Section titled “The exchange, in three steps”- Ask for a challenge.
GET /browser-challenge?api_key=<key>returns anonce, adifficultyand anexpires_in. - Solve it. Find a number whose SHA-256 hash, appended to the nonce, starts with
difficultyzero bits. This normally takes 50–200 ms. - Trade it for a token.
POST /browser-tokenwith the key, the nonce, your solution and the channel. You get back anaccess_tokenand itsexpires_in.
Both calls go to the auth host, not the API host.
Ask for a challenge
Section titled “Ask for a challenge”curl 'https://auth.eu1.luigisbox.ai/browser-challenge?api_key=pub_live_eu1_CNA1QBVS7PQzKNDn' \ -H 'Origin: https://shop.example.com'The response:
{ "nonce": "eyJpcCI6IjIwMy4wLjExMy43Iiwi….3f9c2a…", "difficulty": 20, "expires_in": 120}The nonce is signed and bound to the requesting address, the key and the origin, so it can
only be redeemed by the browser that asked for it, from the same page, within
expires_in seconds.
Solve it
Section titled “Solve it”Hash nonce + solution and count the leading zero bits of the digest:
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;}Run this in a Web Worker to keep the main thread free.
Trade it for a token
Section titled “Trade it for a token”const response = await fetch('https://auth.eu1.luigisbox.ai/browser-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: 'pub_live_eu1_CNA1QBVS7PQzKNDn', pow_nonce: nonce, pow_solution: solution, channel_id: 'lbn_4hj9tv', audience: 'https://api.eu1.luigisbox.ai/discovery', }),});
const { access_token, expires_in } = await response.json();channel_id is required and must be the channel the key is bound to. audience names the
products the token is for — …/discovery to search and browse, …/events to report
behaviour — and it accepts an array, so one token can cover both:
{ "api_key": "pub_live_eu1_CNA1QBVS7PQzKNDn", "pow_nonce": "…", "pow_solution": 918273, "channel_id": "lbn_4hj9tv", "audience": [ "https://api.eu1.luigisbox.ai/discovery", "https://api.eu1.luigisbox.ai/events" ]}A storefront normally holds one token covering both.
These are the only two audiences a browser token can hold. Naming a different one — indexing, platform configuration, object history — is not an error, but the token comes back with no permissions, so every call made with it is refused.
A reusable token provider
Section titled “A reusable token provider”Wrap the whole exchange in something that hands out a token and refreshes it a little early, and reuse that across your integration.
function createTokenProvider({ apiKey, channelId, audience, authHost }) { let cached = null;
async function mint() { const challengeUrl = `${authHost}/browser-challenge?api_key=${encodeURIComponent(apiKey)}`; const { nonce, difficulty } = await fetch(challengeUrl).then((r) => r.json()); const solution = await solveChallenge(nonce, difficulty);
const { access_token, expires_in } = await fetch(`${authHost}/browser-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey, pow_nonce: nonce, pow_solution: solution, channel_id: channelId, 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 function getToken() { if (!cached || Date.now() >= cached.expiresAt) cached = await mint(); return cached.token; };}Mint the first token while the page is loading, not on the first keystroke, so the challenge does not delay the first request.
Session continuity
Section titled “Session continuity”X-Lbx-Visitor-Id travels with every discovery request and every analytics event, and it
is not part of authentication — it is a stable identifier for the browser that your
integration generates and persists.
Re-minting a token does not reset it. Keep the same visitor identifier across any number of token refreshes; it is what joins a shopper’s API calls to their analytics events. See Requests and responses.
When the exchange fails
Section titled “When the exchange fails”| Symptom | Cause |
|---|---|
403 on /browser-token, message mentions origin | The page’s origin is not registered for the key |
403, message mentions the nonce or IP | The challenge was redeemed from a different address, or reused |
403, message mentions difficulty | The solution does not satisfy the challenge — check that you hash nonce + solution as a string |
| Nonce expired | More than expires_in seconds elapsed; ask for a fresh challenge |
403 on a discovery or events call with a fresh token | The channel_id did not match the key’s channel, or the audience you are calling was not in the token request |
The token works, but private: fields are missing | Expected — browser tokens never see them |
The challenge and the token call must come from the same address, so a DNS or CDN change between the two can fail the exchange within the nonce’s lifetime. Retry once.
See also
Section titled “See also”- Authentication overview — audiences and permissions
- Browser quickstart — this flow wired to a search box
- Server-to-server tokens — the backend equivalent
Was this page helpful?
Thanks.