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.

    Catalogs and channels

    View source

    Two entities decide how an account is modelled.

    IsAnswers
    CatalogYour data, in one languageWhat exists?
    ChannelA view over one catalog, for one serving destinationWhat does this storefront see, and under which names?

    A catalog holds the objects and owns the language. A channel decides which of those objects a particular storefront may see and which of their values it gets. You index once per language and serve many destinations.

    A German-language business sells in Germany, Austria and Switzerland. Same products, same German text — but different prices, different availability, and a handful of products that may not be sold across every border.

    That is one catalog and four channels:

    One catalog, four channelsYour product data is indexed once into a German-language catalog, which four channels serve: example.de, example.at, example.ch and example.com/de.Your product dataCatalogde_DECHANNELSexample.deexample.atexample.chexample.com/deindex

    Each product is indexed once, carrying every market’s values side by side:

    {
    "@id": "product/sku-1001",
    "@type": "product",
    "@title": "Wanderschuh Alpin",
    "price_de": 129.9,
    "price_at": 134.9,
    "price_ch": 149.0,
    "stock_de": 12,
    "stock_at": 4,
    "stock_ch": 0,
    "markets": ["de", "at"]
    }

    Then each channel is configured to show its own slice. Your storefront asks for price and gets that market’s value.

    Four catalogs means four copies of the same German content — four times the indexing, four places to fix a typo, and four separate pools of behavioural data. Ranking learns from what shoppers do, so splitting one market’s traffic four ways slows down every copy.

    One catalog keeps the content and the learning together. Channels differ only where the markets differ.

    A catalog is created with a language, which decides how text is analyzed:

    { "organization_id": "lbo_r2vn8c", "name": "Example — German", "language": "de_DE" }

    de_DE selects German stemming and compound handling, so a search for “Schuhe” finds Wanderschuhe. A different language is always a different catalog. A different market in the same language is a channel.

    Austrian German and German German share a catalog. German and French do not.

    Which objects a channel may serve at all.

    A channel can carry a visibility_filter: a condition that every request on that channel is narrowed by. An object the filter excludes is not reachable on the channel at all — not by search, not by a listing, not by a direct lookup, not by a recommendation.

    Terminal window
    curl -X POST 'https://api.eu1.luigisbox.ai/platform/v1/channels' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{
    "catalog_id": "lbc_8w3k2p",
    "name": "example.ch",
    "visibility_filter": {
    "operator": "and",
    "conditions": [
    { "field": "markets", "operator": "in", "value": ["ch"] }
    ]
    }
    }'

    That channel now serves only products whose markets includes ch. The product above, sold in Germany and Austria only, is not served on example.ch.

    A shopper’s own filters are applied on top of the visibility filter, never instead of it. No request widens past it.

    The shape is the same condition tree used by merchandising rules — an operator of and, or or not, and a conditions array whose entries are either leaves (field, operator, value) or nested groups. The same limits apply: 10 levels deep, 100 conditions.

    The shorthand form some tools accept — {"stock": {"gt": 0}} — is rejected. Write the canonical group.

    It is validated against your catalog when you save it, not when it first runs. A field your catalog has never carried, or an operator its type does not support — gt on a text field — is a 422 at write time. Such a filter would match nothing at query time without reporting an error, leaving the channel serving no products.

    Validation needs a catalog that has content. Neither visibility_filter nor attribute_aliases can be set before the catalog’s first ingest — until then there is nothing to check the names against, and a write naming either field is a 422. Create the channel without them and set them once the catalog has been fed.

    Send null to clear a visibility filter. Omitting the field leaves it unchanged — see Partial updates.

    Which values a channel serves, and under which names.

    attribute_aliases maps a channel-facing name to the catalog field that holds the value:

    {
    "catalog_id": "lbc_8w3k2p",
    "name": "example.at",
    "attribute_aliases": {
    "price": "price_at",
    "stock": "stock_at"
    }
    }

    On this channel, price means price_at. The key is the name your storefront uses; the value is the catalog field that holds the value.

    It works in both directions:

    DirectionEffect
    InboundA filters, sort, facet or return_fields entry naming price resolves to price_at
    OutboundThe hit comes back keyed price, holding the Austrian number

    One storefront codebase serves every market: it filters on price, sorts by price and renders hit.price, and the channel in the request decides which catalog field that is.

    Terminal window
    curl -X POST 'https://api.eu1.luigisbox.ai/discovery/v1/search?channel_id=lbn_7q2m4d&surface_id=lbs_search_main' \
    -H 'Authorization: Bearer <token>' \
    -H 'X-Lbx-Visitor-Id: 8f14e45f-ea0f-4b5c-9a1d-2b3c4d5e6f70' \
    -H 'Content-Type: application/json' \
    -d '{
    "type": "product",
    "query": "wanderschuh",
    "filters": { "price": { "lt": 140 } },
    "return_fields": ["@title", "price", "stock"]
    }'

    The same request against lbn_9x5t3b returns 129.9 and 12. The channel is the only part of the request that differs.

    A response answers in the names the request used

    Section titled “A response answers in the names the request used”

    An alias does not hide the catalog field. Both names address it, and each hit is keyed by whichever of the two the request wrote.

    On the example.at channel above, which aliases price and stock:

    RequestResult
    "filters": { "price": { "lt": 140 } }Resolves to price_at; the hit comes back keyed price
    "sort": { "field": "price", "direction": "asc" }Sorts on price_at
    "filters": { "price_at": { "lt": 140 } }Works too; the hit comes back keyed price_at
    "return_fields": ["price_at"]Returns that field, keyed price_at
    "return_fields": ["price", "price_at"]Returns it once, keyed both ways
    "filters": { "stock_de": { "gt": 0 } }Works — this channel has no alias for stock_de, so it is an ordinary field

    A request that names one field both ways receives it once, keyed both ways. Nothing is rejected.

    return_fields=* and an omitted return_fields both mean every field, and a request that enumerates nothing has named neither vocabulary. Every aliased field then comes back under both names — price and price_at, stock and stock_at. The keys such a hit carries depend on the channel alone: adding a filter or a sort does not change them.

    When the request does enumerate its fields, it has stated which vocabulary it wants, and each field is keyed the way that request wrote it — see the table above.

    Error messages speak the names you used. A rejected filter on price quotes price; the same filter written on price_at quotes price_at.

    Anything you do not bind. @title, brand, color — if the channel has no entry for a name, it means what it means in the catalog, in both directions. Alias the fields that vary by destination and leave the rest unbound.

    They are also single-pass: a name resolves once and is never fed back through the map.

    Each of these is a 422 at write time:

    RejectedBecause
    A key naming a field the catalog already hasIt would leave that field reachable under no name at all
    Two keys bound to the same catalog fieldRenaming the response back would be ambiguous
    A value naming no field in the catalogThe alias could never resolve
    @id or @type on either sideEvery hit carries its identity under its own name
    A dotted path on either sideNested data comes back nested, so aliasing it to a flat name is not supported
    * as a keyIt is the return-fields wildcard, and binding it would rewrite the wildcard itself
    Empty, blank or space-padded namesA catalog attribute never has a padded name

    Checking a name against the catalog needs a catalog with content, so an alias map cannot be set before the catalog’s first ingest either.

    The first rule in that table is checked when you write the alias, against the fields the catalog carries at that moment. Field types follow ingested data, so a catalog can grow a field later that an existing alias already uses as its key.

    If that happens, the real field wins: the alias stops taking effect on that channel, in both directions. The name resolves to the catalog’s own field, and hits carry that field’s value under it. A field your catalog carries is always reachable under its own name; the alias gives way.

    The alias is not deleted, and nothing else about the channel changes. Remove it, or rename its key, and the binding works again. Until then it has no effect. Prefer alias keys your catalog will not grow on its own — a storefront-facing name like price rather than a feed-shaped one like price_at_net.

    Put each market’s value in its own field on one object. price_de, price_at, price_ch — flat, one object per product.

    • Do not create one product per market. It duplicates content and splits ranking signal per market.
    • Do not nest per-market values (prices.de). Nested data comes back nested, and aliasing a nested path to a flat channel name is not supported.
    • Do carry a market list (markets: ["de", "at"]) if products differ by where they may be sold. It gives visibility filters something to read, and adding a market needs no schema change.
    • Do keep names predictable. A consistent <field>_<market> convention makes each new channel a two-line configuration.

    A surface is a configured discovery experience — a search results page, an autocomplete panel, a recommendation strip — and it is scoped to the catalog, not the channel. Every channel over a catalog can serve every surface on it.

    Adding Switzerland is one channel and no surface work: the search configuration, the ranking, and the merchandising are already there. A request names both — surface_id for which experience, channel_id for which market.

    • A catalog per domain. Four copies of one language, and ranking learning split four ways.
    • A channel per language. Language decides text analysis, and that lives on the catalog. Two languages are two catalogs.
    • Naming one field both ways in one request. On a channel that aliases price to price_at, those are one field, and a request writing both is rejected. Send the alias.
    • Aliasing a name your catalog may grow later. The write is rejected only if the field exists then. If a later ingest adds it, the real field takes the name back and the alias goes inert — see When the catalog grows an aliased name.
    • Expecting per-market products to be filtered automatically. Nothing infers which markets a product belongs to — a visibility filter reads a field you populate.
    • Setting a visibility filter and forgetting it. It narrows every request on the channel. When a channel returns fewer results than you expect, read its filter first.