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 tokens

    View source

    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.

    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.

    Bound toEffect
    One channelResults and events for that channel only. A request naming a different channel is rejected, even if your account owns it.
    A list of originsA token request from an unregistered origin is rejected, and the issued token stays bound to the origin it was minted for
    One region and environmentA 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.

    1. Ask for a challenge. GET /browser-challenge?api_key=<key> returns a nonce, a difficulty and an expires_in.
    2. Solve it. Find a number whose SHA-256 hash, appended to the nonce, starts with difficulty zero bits. This normally takes 50–200 ms.
    3. Trade it for a token. POST /browser-token with the key, the nonce, your solution and the channel. You get back an access_token and its expires_in.

    Both calls go to the auth host, not the API host.

    Terminal window
    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.

    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.

    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.

    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.

    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.

    SymptomCause
    403 on /browser-token, message mentions originThe page’s origin is not registered for the key
    403, message mentions the nonce or IPThe challenge was redeemed from a different address, or reused
    403, message mentions difficultyThe solution does not satisfy the challenge — check that you hash nonce + solution as a string
    Nonce expiredMore than expires_in seconds elapsed; ask for a fresh challenge
    403 on a discovery or events call with a fresh tokenThe 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 missingExpected — 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.