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.

    Filters

    View source

    Filters are a JSON tree in the request body. One object restricts a result set:

    {
    "$and": [
    { "availability": { "eq": 1 } },
    { "price": { "lt": 50 } },
    { "brand": { "in": ["northwear", "atlas"] } }
    ]
    }

    This page is the reference for that syntax. The node grammar and the evaluation rules are the same wherever Luigi’s Box AI accepts a filter; what changes between surfaces is which operators the surface accepts. Discovery takes one on three endpoints:

    SurfaceField
    Searchfilters
    Collectionsfilters (the shopper’s narrowing); the page itself is named by collection_id
    Recommendationsfilters

    Nothing needs quoting or escaping: a value with spaces, commas or quotes is a JSON string, and a field name with a colon is a JSON key.

    Search surfaces can also infer constraints from the query. See Query interpretation for supported price locales, inferred facet boosts and currency requirements.

    A filter is a tree of nodes. Every node is one of two things.

    A condition node maps field names to operator objects:

    { "brand": { "eq": "northwear" } }

    A group node holds exactly one of $and, $or or $not:

    { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] }

    $and and $or take an array of nodes. $not takes a single node. A node may not mix the two forms — a $-key and a field name in the same object is a 422.

    Two shorthands reduce $and wrappers. Several operators on one field combine as AND:

    { "price": { "gte": 25, "lte": 75 } }

    And several fields in one node combine as AND:

    { "availability": { "eq": 1 }, "color": { "eq": "blue" } }

    $and is needed to place a group beside a condition, or to repeat a field:

    {
    "$and": [
    { "availability": { "eq": 1 } },
    { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] }
    ]
    }

    There is no operator precedence; nesting is explicit.

    OperatorMeansValueExample
    eqEqualsScalar{ "color": { "eq": "blue" } }
    neqDoes not equalScalar{ "color": { "neq": "blue" } }
    gt gte lt lteNumeric comparisonScalar{ "price": { "gte": 25 } }
    inIs one ofNon-empty array{ "brand": { "in": ["northwear", "atlas"] } }
    not_inIs none ofNon-empty array{ "color": { "not_in": ["black", "white"] } }
    existsField is presenttrue / false{ "image_url": { "exists": true } }

    in and not_in require at least one entry. There is no empty-set form — when a shopper has selected no brands, omit the condition rather than sending an empty array.

    const clauses = [{ availability: { eq: 1 } }];
    if (brands.length) clauses.push({ brand: { in: brands } });
    const filters = clauses.length === 1 ? clauses[0] : { $and: clauses };

    { "exists": false } is the same as wrapping { "exists": true } in $not. Both mean “objects that do not carry this field at all”.

    A field is a catalog attribute name, used verbatim — the same name you indexed it under, as a JSON key.

    { "color": { "eq": "blue" } }
    { "price": { "lte": 50 } }
    { "@category": { "in": ["category/shirts"] } }
    { "lbx:group_primary": { "eq": true } }
    { "params.material": { "eq": "cotton" } }
    { "my odd field": { "eq": 1 } }

    Namespaced names contain a colon, nested attributes use a dot-path, and a name with spaces or punctuation needs nothing special — a JSON key holds any of them as-is. The only restrictions are that a field name must not begin with $, which is reserved for group keys, and must not have leading or trailing whitespace.

    To see which attributes a catalog holds, list its attributes and their values.

    A value is a JSON scalar: a string, a number, a boolean, or null.

    { "in_stock": { "eq": true } }
    { "price": { "gt": -0.5 } }
    { "category": { "eq": "musical instrument" } }
    { "brand": { "eq": "Atlas & Co" } }
    { "name": { "eq": "salt and pepper" } }

    Types must match the field’s. A number compared against a text field is a filter_type_mismatch.

    A filter holds at most 100 conditions, in groups nested at most 10 deep — the same condition and depth limits a channel’s visibility_filter and a merchandising rule’s conditions have, counted the same way:

    • Conditions are counted, groups are not. Each operator is one condition, so { "lbx:price": { "gte": 50, "lte": 200 } } is two. How they are spread across $and / $or arrays does not matter; only the total does. Fifty selected brands are one in condition, not fifty.
    • Depth counts groups. The outermost group is level 1, and a condition inside a group is not a level of its own. A condition node with several operators or several fields, or an exists: false, is a group of its own, one level deeper.

    A filter past either limit is a 422 with code filter_too_complex: as the type of its validation_errors entry in a request body, and as exception_details.error_code for a filters query parameter. A filter far past them — a few dozen levels deep, or thousands of conditions — is refused the same way before it is read.

    Unlike a stored filter, an in / not_in list here has no 100-value limit.

    Not every object has every attribute. When an attribute is missing:

    • A positive test on a missing attribute is false. eq, in, gt, gte, lt, lte do not match an object that lacks the attribute.
    • A negative test on a missing attribute is true. neq and not_in do match it.

    For example:

    { "color": { "neq": "blue" } }

    matches every object with no color at all, alongside the red and green ones. For “has a colour, and it is not blue”, add exists:

    { "color": { "neq": "blue", "exists": true } }

    Filters you save as configuration are the exception: they add that exists for you. A neq or not_in in a campaign trigger or rule selector, in a channel’s visibility filter, or in a curated collection’s filters, is stored with a presence check on the same field, so a rule that bans products outside a category leaves products with no category alone, and a not around a single condition is pinned the same way, from the outside — though never to exists itself, since asking for objects that lack a field is a deliberate thing to write and pinning it would make it match nothing. The write’s own response carries the stored form, so you see what was saved.

    Four cases are left alone, so reading a saved campaign or channel back shows no added clause. The slots every object carries — @id, @type and @updated_at — need no check, since a negation on one always has a value to compare against. @title is not among them: objects stored before titles were required may carry none, so a negation on it is paired like any other. Nor does any trigger.request:* field: those are absent when the request is of another kind, not when a product happens to lack an attribute, and a campaign says which kinds it fires on through its targeting. A field you already paired with your own exists keeps the pairing you wrote rather than gaining a second one. And a not wrapped around a whole group is left as written: which of its fields would have to be present is not one question, so there is no single clause that answers it.

    To save a condition that does match objects missing the field — “not blue, and colourless is fine” — write both halves out:

    {
    "operator": "or",
    "conditions": [
    { "operator": "not", "conditions": [{ "field": "color", "operator": "exists" }] },
    {
    "operator": "and",
    "conditions": [
    { "field": "color", "operator": "exists" },
    { "field": "color", "operator": "neq", "value": "blue" }
    ]
    }
    ]
    }

    Either it has no color at all, or it has one and that one is not blue — which is what the bare neq used to mean. It is stored exactly as sent: exists is never rewritten, and the neq sits in an and that already pins its field.

    Nowhere else is it added for you: in a discovery filter the rule above holds, and the exists is yours to write.

    Everything in stock under €50, from two brands:

    {
    "availability": { "eq": 1 },
    "price": { "lt": 50 },
    "brand": { "in": ["northwear", "atlas"] }
    }

    Two categories, excluding clearance:

    {
    "@category": { "in": ["category/shirts", "category/knitwear"] },
    "clearance": { "neq": true }
    }

    A price band, either of two colours:

    {
    "$and": [
    { "price": { "gte": 25, "lte": 75 } },
    { "$or": [{ "color": { "eq": "blue" } }, { "color": { "eq": "navy" } }] }
    ]
    }

    Only the primary product of each variant group:

    { "lbx:group_primary": { "eq": true } }

    Everything except what is already in the cart:

    {
    "availability": { "eq": 1 },
    "@id": { "not_in": ["product/sku-1001", "product/sku-2087"] }
    }

    Only products that have an image:

    { "image_url": { "exists": true } }

    Rebuild the tree from the selected values on each request rather than mutating one across requests; this keeps the “clear all filters” case correct:

    function buildFilters({ brands, maxPrice, inStockOnly }) {
    const clauses = [];
    if (brands.length) clauses.push({ brand: { in: brands } });
    if (maxPrice != null) clauses.push({ price: { lte: maxPrice } });
    if (inStockOnly) clauses.push({ availability: { eq: 1 } });
    if (clauses.length === 0) return undefined; // omit the field entirely
    if (clauses.length === 1) return clauses[0];
    return { $and: clauses };
    }

    Omit filters rather than sending null or {} when there is nothing to filter on: an empty object is not a valid node.

    A filter is checked in two stages, and the two rejections have different shapes.

    A tree that does not match the node grammar — an unknown operator, an empty in array, a node mixing $and with a field name, anything over a size limit — is rejected before the request is served, as a validation error. Each entry’s loc names the offending path:

    {
    "reason": "Request validation failed",
    "exception_details": {
    "validation_errors": [
    {
    "loc": ["body", "filters"],
    "msg": "Value error, filters.$and[2]: unknown group operator '$nor' (allowed: $and, $or, $not)",
    "type": "value_error"
    }
    ]
    }
    }

    A tree that is well-formed but does not fit the catalog is rejected when it is applied, and carries an error_code instead:

    exception_details.error_codeCause
    filter_unknown_fieldThe field is not present in this catalog
    filter_type_mismatchThe value’s type does not match the field’s — a number compared to text

    A filters query parameter carries an error_code for its own refusals too: filter_syntax when the expression does not parse, and filter_too_complex when it is past the size limits.

    A filter_type_mismatch usually means the attribute was indexed as text when you expected a number: price arriving as "29.90" rather than 29.90 in one feed run types the field as text for good. Check the field’s type with catalog metadata and fix it at the mapping, not in the filter.

    Everything above about values and evaluation holds wherever a filter appears. Four things vary by surface: which operators it accepts, how the tree is written, how large it may be, and whether a negation gets its presence check added for you.

    SurfaceWritten asOperatorsSizeNegations
    Discovery — search, collection, recommendationsThe map syntax on this pageThe nine aboveThe limits aboveAs written
    Business rules — match and record_matchfield / operator / value leaves in a conditions array, or the map syntaxThose nine, plus matches for substring and range for a bounded interval100 conditions in the whole tree, in groups nested up to 10 deep, and 100 values per in / not_in, with no bound per arrayPresence added on save
    Channel visibility — visibility_filterEither syntax, as business rulesThe same as business rulesThe same as business rulesPresence added on save
    Curated collections — filtersThe map syntax on this pageThe nine aboveThe same as business rules, and 64 KiB once storedPresence added on save
    // A business rules leaf, for comparison with a condition node above
    { "field": "brand", "operator": "eq", "value": "northwear" }

    A surface rejects an operator it does not accept; each surface’s own reference lists the operators it takes. For campaigns, the trigger field metadata reports the operators each field accepts.