---
title: Catalogs and channels
description: One catalog per language, many channels over it — and the two mechanisms, visibility filters and attribute aliases, that let one set of products serve several markets.
slug: concepts/catalogs-and-channels
docKind: concept
hub: luigisbox-ai
---
Two entities decide how an account is modelled.
| | Is | Answers |
|---|---|---|
| **Catalog** | Your data, in one language | *What exists?* |
| **Channel** | A view over one catalog, for one serving destination | *What 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.**
## The case this is built for
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**:
Each product is indexed **once**, carrying every market's values side by side:
```json
{
"@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.
### Why not a catalog per domain
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.
## Language belongs to the catalog
A catalog is created with a `language`, which decides how text is analyzed:
```json
{ "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.
:::hub[Config → Channels]{path="/channels"}
Channels are created and edited here — name, catalog, visibility conditions and attribute
projections — without calling the API. Its condition rows are single comparisons, combined
with AND.
:::
## Visibility filters
**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.
```bash
curl -X POST 'https://api.eu1.luigisbox.ai/platform/v1/channels' \
-H 'Authorization: Bearer ' \
-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.
### Writing one
The shape is the same condition tree used by
[merchandising rules](/merchandising/business-rules/triggers/#condition-syntax) — 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](/api-basics/requests-and-responses/#partial-updates).
## Attribute aliases
**Which values a channel serves, and under which names.**
`attribute_aliases` maps a **channel-facing name** to the **catalog field** that holds
the value:
```json
{
"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**:
| Direction | Effect |
|---|---|
| Inbound | A `filters`, `sort`, facet or `return_fields` entry naming `price` resolves to `price_at` |
| Outbound | The 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.
```bash
curl -X POST 'https://api.eu1.luigisbox.ai/discovery/v1/search?channel_id=lbn_7q2m4d&surface_id=lbs_search_main' \
-H 'Authorization: Bearer ' \
-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"]
}'
```
```json
{
"hits": [
{ "@id": "product/sku-1001", "@title": "Wanderschuh Alpin", "price": 134.9, "stock": 4 }
]
}
```
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
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`:
| Request | Result |
|---|---|
| `"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`.
### What passes through untouched
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.
### What the API rejects, and why
Each of these is a `422` at write time:
| Rejected | Because |
|---|---|
| A key naming a field the catalog already has | It would leave that field reachable under no name at all |
| Two keys bound to the same catalog field | Renaming the response back would be ambiguous |
| A value naming no field in the catalog | The alias could never resolve |
| `@id` or `@type` on either side | Every hit carries its identity under its own name |
| A dotted path on either side | Nested data comes back nested, so aliasing it to a flat name is not supported |
| `*` as a key | It is the return-fields wildcard, and binding it would rewrite the wildcard itself |
| Empty, blank or space-padded names | A 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.
### When the catalog grows an aliased name
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`.
## Modelling your data for this
**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 `_` convention makes each new
channel a two-line configuration.
## Where surfaces fit
A [surface](/platform/account-structure/#surfaces) 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*.
## Common mistakes
- **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](#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.
## See also
- [Domain model](/concepts/domain-model/) — how organizations, catalogs, channels and surfaces relate
- [Account structure](/platform/account-structure/) — creating and managing all four over the API
- [Catalog object model](/concepts/catalog-object-model/) — the shape of the objects you index
- [Filters](/discovery/filters/) — the operators a visibility filter uses