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.

    Account structure

    View source

    Every call you make names at least one of these entities. Most accounts are set up once with Luigi’s Box and rarely change shape afterwards, but reading them — and adding a market or a placement — is something you can do yourself.

    The domain model explains what these entities are. This page is about working with them over the API.

    Organization lbo_
    └── Catalog lbc_
    ├── Channel lbn_
    ├── Surface lbs_<intent>_<slug>
    └── Collection collection:…
    Tag lbt_ (groups catalogs, channels or surfaces)

    Everything here is under /platform/v1/, needs a token for the https://api.<region>.luigisbox.ai/platform audience, and a grant to read or manage kernel entities — reading, writing and deleting are granted separately.

    One call returns the whole tree your credentials can reach. It is paginated by organization and defaults to 20, so ask for a page large enough to hold it:

    Terminal window
    curl -G 'https://api.eu1.luigisbox.ai/platform/v1/me/entitlements' \
    -d 'size=1000' \
    -H 'Authorization: Bearer <token>'

    Everything your credentials can reach, nested:

    {
    "items": [
    {
    "id": "lbo_r2vn8c",
    "name": "Example Retail",
    "catalogs": [
    {
    "id": "lbc_8w3k2p",
    "name": "Example — EN",
    "language": "en_GB",
    "channels": [{ "id": "lbn_4hj9tv", "name": "example.com" }],
    "surfaces": [
    { "id": "lbs_search_main", "intent": "search", "slug": "main" }
    ]
    }
    ]
    }
    ],
    "page": 1,
    "size": 1000,
    "total": 1
    }

    The response is scoped to your own credentials — it lists exactly what you are entitled to and nothing else — so it answers which catalog to index into and which channel to search on.

    Paginated by organization with page and size, in the same envelope every list on the API uses: one items entry is one organization, with its whole subtree nested inside, and total counts organizations. size defaults to 20 and accepts up to 1000 here — higher than the usual limit of 100, so a token entitled to many organizations can read the whole tree in one call.

    Your company. The billing entity and the root of everything else.

    GET /platform/v1/organizations
    POST /platform/v1/organizations
    GET /platform/v1/organizations/{organization_id}
    PATCH /platform/v1/organizations/{organization_id}
    DELETE /platform/v1/organizations/{organization_id}

    An organization has a name and nothing else to configure. Most integrations only ever read this.

    A catalog is one set of objects in one language, with its own settings. Separate catalogs are how you handle separate languages or regions.

    Catalogs are created by Luigi’s Box: POST /platform/v1/catalogs needs credentials that span every organization, so ask your Luigi’s Box contact for a new one. The request below is what that call takes.

    Terminal window
    curl -X POST 'https://api.eu1.luigisbox.ai/platform/v1/catalogs' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{
    "organization_id": "lbo_r2vn8c",
    "name": "Example — German",
    "language": "de_DE"
    }'
    FieldRequiredNotes
    organization_idYes
    nameYes
    languageYesxx_XX — a lowercase language, an underscore, an uppercase region
    settingsNoCatalog-level settings
    seed_surface_templatesNoTemplates to seed the catalog’s first surfaces from

    The language decides how text is analyzed: de_DE gets German stemming and compound handling, so a search for “Schuhe” finds Wanderschuhe.

    A new catalog comes with two surfaces, each already serving a first version: lbs_search_default and lbs_collection_default. The collection surface answers with products only; catalogs created before it was added don’t have it, so ask your Luigi’s Box contact to add one. A new catalog has no recommender surface: each recommender gets its own when it is set up. Older catalogs may also carry lbs_recommender_default, which you can ignore. Templates named in seed_surface_templates add further surfaces on top of these two; recommender templates are not meant for it, as each recommender is set up with its own settings.

    List with ?organization_id=… — that parameter is also the authorization scope, see Status codes. Reading, updating and deleting follow the usual shape.

    A channel is a serving destination and a view over one catalog. Two mechanisms make one catalog serve several markets without duplicating content:

    MechanismFieldWhat it does
    Visibility filtervisibility_filterWhich objects this channel may serve at all
    Attribute aliasesattribute_aliasesWhich of an object’s destination-specific values this channel serves, and under which name

    Catalogs and channels covers both in full, including which of an aliased field’s two names a response is keyed by. What follows is the API surface.

    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.de",
    "attribute_aliases": { "price": "price_de", "availability": "availability_de" }
    }'

    Each product carries several destination-specific values — price_de, price_at — and the channel decides which one answers to the canonical name price. Your storefront asks for price and gets the right one, with no per-request context and no duplicated products.

    Both fields are validated against the catalog when you write them, so a name the catalog does not carry — or an operator its type does not allow — is a 422. The check needs a catalog with content, so neither field can be set before the catalog’s first ingest: create the channel without them and set them once it has been fed. When the check cannot run at all, the write fails with a retryable 503 instead of saving unvalidated. See Attribute aliases for the full list of what is rejected and why.

    visibility_filter is the one clearable field on a channel: send null to remove it. Everything else, omit to leave unchanged. See Partial updates.

    List with ?catalog_id=… for one catalog’s channels, or ?organization_id=… for every channel in an organization. Sending both narrows to the intersection.

    A surface is a configured discovery unit, scoped to a catalog. Every channel bound to that catalog can call any of its surfaces — there is no per-channel duplication.

    Terminal window
    curl -X POST 'https://api.eu1.luigisbox.ai/platform/v1/catalogs/lbc_8w3k2p/surfaces' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{ "intent": "search", "slug": "main" }'
    FieldRequiredNotes
    intentYesThe intent: search, recommender or collection
    slugYesLowercase letters, digits and underscores, starting with a letter, 1–48 characters

    The two combine into the surface’s ID — lbs_search_main — and slugs are unique per catalog and intent. See Surface identifiers.

    A surface’s response reports its default_version. Ranking configuration lives on versions rather than on the surface itself, which is what lets an A/B test run several versions of one surface against each other without duplicating it. Version authoring is done with Luigi’s Box; the surface endpoints here create, read, list and delete the surface itself.

    GET /platform/v1/catalogs/{catalog_id}/surfaces
    POST /platform/v1/catalogs/{catalog_id}/surfaces
    GET /platform/v1/catalogs/{catalog_id}/surfaces/{surface_id}
    DELETE /platform/v1/catalogs/{catalog_id}/surfaces/{surface_id}

    A collection is what a collection request serves. Two kinds are registered on the catalog before a request can name them; the third, collection:all/member:{member_type}, lists every object of a type and needs no registration.

    A membership collection turns every object of one type into a listing of another type. One registration covers every category, for example:

    Terminal window
    curl -X POST 'https://api.eu1.luigisbox.ai/platform/v1/catalogs/lbc_8w3k2p/collections' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{
    "kind": "membership",
    "collection_type": "category",
    "member_type": "product",
    "membership_attribute": "lbx:category_ids",
    "ancestry_attribute": "lbx:category_ids"
    }'
    FieldRequiredNotes
    collection_typeYesObject type whose objects are the collections, e.g. category
    member_typeYesObject type the collection returns, e.g. product
    membership_attributeYesAttribute of the member type holding the @id of every collection object the member belongs to, e.g. lbx:category_ids
    ancestry_attributeNoAttribute of the collection type holding each object’s ancestor @ids, e.g. lbx:category_ids; omit it for a type without hierarchy

    lbx:category_ids is derived from your categories’ @parent. On a product it holds every category the product is in and every category above them, so collection:category/clothing/member:product lists the products of its subcategories too. On a category it holds the category’s ancestors, then the category itself. Without @parent, a category’s collection lists only the products that reference it directly.

    Its ID is collection:category/member:product, and one registration exists per collection_type and member_type pair. A request serves one category of it as collection:category/shirts/member:product.

    A curated collection is one named filter over a member type.

    {
    "kind": "curated",
    "slug": "new_arrivals",
    "name": "New Arrivals",
    "member_type": "product",
    "filters": { "label": { "eq": "new" } }
    }
    FieldRequiredNotes
    slugYesYour part of the ID: lowercase ASCII letters, digits, _ and -, up to 256 characters
    nameYesShown where the collection is listed; it takes no part in the ID
    member_typeYesObject type the collection returns
    filtersYesA filter tree in map syntax over the member type; negations are stored with an exists, see missing attributes

    Its ID is collection:curated/new_arrivals/member:product, so you know it before the request is answered. slug is taken as sent: a value outside the format above is a 422, never adjusted. One slug registers one collection per member type; registering a second one is a 409. A request names a curated collection by this ID and lists the objects matching filters.

    Only filters can change after registration:

    Terminal window
    curl -X PATCH 'https://api.eu1.luigisbox.ai/platform/v1/catalogs/lbc_8w3k2p/collections/collection:curated/new_arrivals/member:product' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{ "filters": { "label": { "eq": "new" }, "stock": { "gt": 0 } } }'

    The response is the collection with the new filters and a later updated_at. Updating a membership collection is a 422.

    A field of the other kind is a 422. Types and attributes are checked against what the catalog has ingested, so register collections after the catalog’s first feed run:

    • an unknown type or attribute is a 422 naming it,
    • an attribute starting with @ skips that check, though a filter value on one is still checked,
    • an attribute in the private:, signals:, ml: or lexical: namespace is a 422,
    • a catalog nothing has been ingested into yet is a 422.

    A catalog holds at most 1,000 collections. Apart from a curated collection’s filters, a collection cannot be changed; delete it and register it again. Its ID contains / and is sent in the path as is.

    GET /platform/v1/catalogs/{catalog_id}/collections
    POST /platform/v1/catalogs/{catalog_id}/collections
    GET /platform/v1/catalogs/{catalog_id}/collections/{collection_id}
    PATCH /platform/v1/catalogs/{catalog_id}/collections/{collection_id}
    DELETE /platform/v1/catalogs/{catalog_id}/collections/{collection_id}

    A tag is a named group, and it exists so that things which target many entities — chiefly campaigns — can target a group instead of a list.

    One tag groups exactly one dimension:

    dimensionMembers are
    catalogCatalog IDs
    channelChannel IDs
    surface{catalog_id, surface_id} pairs
    Terminal window
    curl -X POST 'https://api.eu1.luigisbox.ai/platform/v1/tags' \
    -H 'Authorization: Bearer <token>' \
    -H 'Content-Type: application/json' \
    -d '{
    "organization_id": "lbo_r2vn8c",
    "dimension": "catalog",
    "name": "market:eu",
    "members": ["lbc_8w3k2p", "lbc_4hj9tv"]
    }'

    Surface tags name the catalog alongside the surface, because a surface ID is only unique within its catalog:

    {
    "organization_id": "lbo_r2vn8c",
    "dimension": "surface",
    "name": "surface-group:all-search",
    "members": [
    { "catalog_id": "lbc_8w3k2p", "surface_id": "lbs_search_main" },
    { "catalog_id": "lbc_4hj9tv", "surface_id": "lbs_search_main" }
    ]
    }

    At least one member is required, and dimension is immutable — changing it would orphan the members.

    A PATCH edits membership as a delta rather than replacing the set: add_members and remove_members each name only what changes, so two people editing different members of the same tag cannot erase each other’s work. Both are applied idempotently, and the two lists must not overlap.

    A tag name is namespace:value — a lowercase namespace, a colon, then the value:

    market:eu
    season:black-friday
    surface-group:all-search

    The namespace is yours to choose, and its job is to say what kind of grouping the tag is, so that a list of them reads as an index rather than a pile. Pick one per axis you group along — market:, season:, campaign-type: — and reuse it; a namespace nobody reuses is a prefix, not a namespace. Names are lowercased on the way in, so Market:EU and market:eu are the same tag.

    The namespace must start with a letter and may hold letters, digits, - and _. The value after the colon may not contain whitespace or a further colon.

    Some namespaces are reserved for tags Luigi’s Box manages itself — currently intent, language and recommender. Writing or deleting a tag in one of those is refused with a 403 naming the namespace, unless your token carries the matching reserved-tags permission, granted on the tag’s organization or wider. Choose your own namespace and the question never arises.

    A tag cannot be deleted while it is the only thing narrowing a live campaign on its dimension: that campaign names nothing of its own there, so losing the tag would spread it across the whole dimension rather than stop it. The delete answers 409 and names the campaign. Give the campaign its own ids on that dimension, or archive it, then delete the tag.

    A campaign targeting market:eu resolves that tag to its members, and re-resolves it whenever the tag changes. Add a catalog to the tag and every active campaign targeting it covers the new market within seconds, with no campaign edited.

    Editing a tag therefore changes what every campaign using it serves. Before changing membership, check which campaigns point at it — GET /platform/v1/campaigns?intent=… and each campaign’s effective scope answer that.

    List with ?organization_id=…, optionally &dimension=….

    The usual sequence, when an organization already exists:

    1. Ask Luigi’s Box to create the catalog for the language — see Catalogs.
    2. Register its feeds and mappings — Feed management API.
    3. Create the channel for the storefront, with its visibility filter and attribute projection.
    4. Create any further surfaces the storefront will call (the catalog already has three), and register the collections its listing pages serve.
    5. Ask Luigi’s Box for credentials — a publishable key bound to the new channel, or an OAuth client for your backend.
    6. Verify with /me/entitlements and one search request.

    Steps 2 and 4 determine most of the ranking configuration. Do both with your Luigi’s Box contact.