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.

    Catalog object model

    View source

    A catalog object is the canonical record Luigi’s Box AI indexes after mapping a source feed or API request. Source field names and catalog field names are separate concerns.

    Fields beginning with @ come from a closed registry. Customers cannot define arbitrary @ fields, and an unrecognized @ field is rejected during ingestion.

    The currently supported registered fields are:

    FieldAuthorityRequirementPurpose
    @idClientRequiredStable typed identifier, such as product/123.
    @typeClientRequiredObject type. A product uses exactly product.
    @titleClientRequiredHuman-readable object name.
    @categoryClientOptionalProduct-to-category relationship using typed category IDs, such as category/cat-t-shirts.
    @brandClientOptionalProduct-to-brand relationship using a typed brand ID.
    @parentClientOptionalThe object’s immediate parent, as a typed ID. Builds category trees — see Hierarchies.
    @group_idClientOptionalNon-empty string shared by all products in one variant group; omitted for standalone products.
    @group_primaryClientOptionalBoolean set to true for the primary product in a group; omitted together with @group_id for standalone products.
    @updated_atSystemSystem-managedLast update timestamp maintained by Luigi’s Box AI. Do not send this field in client feeds or API requests.

    A complete product object can combine registered @ fields with ordinary public attributes:

    {
    "@id": "product/sku-1001",
    "@type": "product",
    "@title": "Blue Cotton T-Shirt",
    "@category": ["category/cat-t-shirts"],
    "@brand": "brand/northwear",
    "@group_id": "northwear-t-shirt-1001",
    "@group_primary": true,
    "url": "https://example.com/products/blue-cotton-t-shirt",
    "image_url": "https://cdn.example.com/products/sku-1001.jpg",
    "price": 29.90,
    "list_price": 39.90,
    "availability": 1
    }

    Canonical object examples are shown as JSON because this is the post-mapping representation. XML, JSON, JSON Lines, and CSV are source feed formats; see the feed type references for examples.

    Use the configured type exactly. A product object uses product.

    The source value used as identity must include the object type prefix:

    product/123

    For a product, the prefix must be product/ and must match the configured @type:

    {
    "@id": "product/123"
    }

    For example, item/123 is rejected when the object type is product; it is not silently rewritten.

    Two registered fields link one object to another. Both hold the target’s typed identity — never a copy of the target itself.

    @category connects a product to categories by typed category ID:

    {
    "@id": "product/123",
    "@type": "product",
    "@title": "Blue Cotton T-Shirt",
    "@category": [
    "category/shirts",
    "category/summer"
    ]
    }

    The category objects use the corresponding IDs:

    {
    "@id": "category/shirts",
    "@type": "category",
    "@title": "Shirts"
    }

    The target category does not need to exist when the product is ingested. Product and category feeds can therefore be processed independently.

    @brand connects a product to a brand by typed brand ID:

    {
    "@id": "product/123",
    "@type": "product",
    "@title": "Blue Cotton T-Shirt",
    "@brand": "brand/northwear"
    }

    The brand object uses the corresponding ID:

    {
    "@id": "brand/northwear",
    "@type": "brand",
    "@title": "Northwear"
    }

    The target brand does not need to exist when the product is ingested. Product and brand feeds can therefore be processed independently.

    @parent expresses a category tree: each category names its immediate parent, and Luigi’s Box assembles the tree from those edges. Do not send a whole path on a category.

    {
    "@id": "category/clothing",
    "@type": "category",
    "@title": "Clothing"
    }

    A child names its parent:

    {
    "@id": "category/shirts",
    "@type": "category",
    "@title": "Shirts",
    "@parent": ["category/clothing"]
    }

    And a grandchild names only its own parent, not the whole chain:

    {
    "@id": "category/dress-shirts",
    "@type": "category",
    "@title": "Dress shirts",
    "@parent": ["category/shirts"]
    }

    The rules:

    • @parent is an array, holding one typed identity — the object’s immediate parent.
    • A root category omits it, or sends an empty array.
    • The parent need not exist yet. Like @category and @brand, the link is stored as sent; ordering between feeds does not matter.
    • Send one edge per object. Naming a grandparent as well has no defined resolution.
    • It is not restricted to categories — any object type may carry it.

    Every variant is a complete product with its own stable @id. Products in the same variant group carry the same string in @group_id; the value is an opaque group identifier and does not need to be a UUID or the @id of another product.

    Use @group_primary: true for the primary product in a group. Both fields are optional. When a group has no primary product, set it to false on all members or omit it. A standalone product omits both fields. Catalog ingestion preserves the optional @ fields exactly as sent; it does not add grouping defaults to the client-owned slots. In a partial update, omitting a field leaves its stored value unchanged; send false explicitly to demote an existing primary product. A partial update can change @group_primary without restating an unchanged group ID. Send @group_id: null to remove a product from its current group; its derived group ID then falls back to its own identity and its derived primary flag becomes false. Catalog ingestion validates each product independently and does not enforce uniqueness across a group. Clients are responsible for sending at most one primary product per group.

    Luigi’s Box derives two search attributes for every product: lbx:group_id copies @group_id or falls back to the untyped identity part of the product’s own @id (product/prod_123 becomes prod_123), and lbx:group_primary copies @group_primary or falls back to false. The fields you supplied are stored unchanged.

    Every group member is stored independently as a complete product. Catalog ingestion does not copy attributes from a primary product onto its variants.

    {
    "@id": "product/t-shirt-blue-m",
    "@type": "product",
    "@title": "Blue Cotton T-Shirt, M",
    "@group_id": "northwear-t-shirt-1001",
    "@group_primary": true,
    "color": "blue",
    "size": "M"
    }

    @group_id must be a non-empty string and @group_primary must be a JSON boolean, not the strings "true" or "false".

    An object is a flat set of fields. A field’s name says who wrote it and who may read it, through a namespace: a prefix ending in a colon, in front of the field name.

    color no prefix
    private:margin the private: namespace
    lbx:color the lbx: namespace

    There are no per-field visibility settings. The prefix decides, so a field’s name says what it is in a feed, in an API payload and in a discovery response.

    FormExampleWritten byWho can read it
    No prefixcolor, priceYouEveryone — including the browser
    @ slot@id, @title, @categoryYou (a closed set)Everyone
    private:private:marginYouServer-side integrations only
    lbx:lbx:color, lbx:group_idLuigi’s BoxEveryone

    Three rules hold across all of them:

    • One prefix, never two. Namespaces do not stack: there is no private:lbx:something.
    • @ is not a namespace. It marks a registered structural slot from a closed set, not a prefix you can extend.
    • An unrecognized prefix is rejected at ingest. A colon in a field name that is not a registered namespace fails the object rather than being stored as a literal name — so a field called size:eu is an error, not an attribute.

    You own the fields with no prefix, the registered @ slots, and everything under private:. Luigi’s Box owns the rest, and sending a field in one of its namespaces is rejected — including lbx: fields derived from what you sent, like lbx:group_id.

    Send what you already have, under the names you already use.

    Beyond the three required slots — @id, @type, @title — there is no prescribed field list. Your attributes keep your names, in your language:

    {
    "@id": "product/sku-1001",
    "@type": "product",
    "@title": "Blue Cotton Dress Shirt",
    "@category": ["category/dress-shirts"],
    "@brand": "brand/northwear",
    "url": "https://example.com/p/blue-cotton-dress-shirt",
    "image_url": "https://cdn.example.com/sku-1001.jpg",
    "price": 29.9,
    "list_price": 39.9,
    "availability": 1,
    "color": "blue",
    "size": "M",
    "material": "cotton",
    "sleeve_length": "long",
    "collar": "spread",
    "care": ["machine wash 30", "iron medium"],
    "private:margin": 8.4
    }

    color, sleeve_length and collar are not predefined fields. They are attributes this shop has, and they are searchable because they were sent.

    Include an attribute if you can answer yes to any of these:

    AskExample
    Would a shopper filter or facet on it?color, size, material, availability
    Would a shopper sort by it?price, rating, created_at
    Does the storefront display it?title, image_url, url, list_price
    Does it describe what the thing is?description, brand, params, specifications

    Ranking works from the text and attributes you provide. Descriptions, specifications and category placement are what let a search for “long sleeve cotton office shirt” find the right product.

    Leave out presentation markup, internal keys nobody will query, and anything personal.

    • Consistent names. The same attribute keeps the same name across every record and every export. color in one product and colour in another are two attributes.
    • Consistent types. The first value a field receives fixes its type for the catalog, and a later conflicting type is rejected rather than coerced. 29.9 is a number; "29.90" is text, and a text price can never be range-filtered or shown as a price slider. See Catalog metadata.
    • Omit rather than fake. A missing value should be absent, not "", 0 or "N/A" — those become real values that show up in facets and match filters.

    Objects are allowed, and nested fields are addressed by dot-path in filters, facets and field projections:

    {
    "params": { "material": "cotton", "fit": "regular" }
    }

    That field is params.material in filters, facets and field projections. Prefer flat attributes unless your source data is nested.

    Fields with no namespace are public: they are eligible to appear in browser-facing discovery responses, depending on the surface configuration. Because browser tokens and their requests are visible in developer tools, a public field must not contain anything a storefront visitor should not be able to read.

    Use the private: namespace for anything that must reach only backend integrations authenticated with a machine-to-machine token — cost, margin, supplier codes, internal notes:

    {
    "color": "blue",
    "price": 29.9,
    "private:margin": 8.4
    }

    Private fields are never returned to a request authenticated with a browser token. Keep machine-to-machine credentials secret; leaking those credentials can expose private fields.

    Which fields a given request actually receives also depends on what it asks for — see Selecting fields.