Categories

The category tree, product membership, and rule-driven categories — the rule language, the dry-run preview, and the chunked recompute you have to drive in a loop.

Two entities: categories is the tree, product_categories is the membership. A category is either hand-picked or rule-driven, and the two kinds of membership live side by side without touching each other.

The tree

Request
curl "https://api.revenexx.com/v1/products/categories?limit=200" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."
FieldMeaning
codeStable identifier. What an import and a storefront join on, and what survives a rename of the label.
labelsThe name a person sees, per language tag. A blank locale falls back to the next filled one.
parent_idThe category this one hangs under. Null is a root.
positionOrder among siblings under the same parent, ascending.
pathA materialized position such as tools/power_tools/cordless_drills, kept for importers that carry one. Nothing in this app writes or reads itparent_id is the structure.
valuesWhatever this catalog keeps on a category beyond the model. The keys belong to the tenant; nothing here reads them.
rules, rule_match, rules_computed_atThe rule, if any. See below.

Deleting a parent lifts its children to the root rather than deleting them, so a mis-click never takes a subtree with it.

Membership

product_categories holds one row per (product, category), however it got there.

FieldMeaning
product_idDeleting the product deletes the membership with it.
category_idThe category it is filed into.
positionSort order of this product inside the category.
sourcemanual was hand-picked, rule was materialized by a category rule.

The two sources never touch each other. A recompute only ever inserts and deletes rule rows, so a hand-picked membership survives every pass.

To file one product by hand, the readable route is on the product:

Request
curl -X POST "https://api.revenexx.com/v1/products/{id}/categories" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"category_code":"cordless_drills"}'

That membership is always source: "manual".

Rule-driven categories

Set rules and the category selects its own members. Null means hand-picked.

Rule
{
  "rule_match": "all",
  "conditions": [
    { "field": "family_id", "operator": "eq", "value": "" },
    { "field": "attribute:material", "operator": "in", "value": ["steel", "aluminium"] },
    { "field": "attribute:discontinued", "operator": "is_empty" }
  ]
}

rule_match is all (AND, the default) or any (OR). Between 1 and 25 conditions — a rule is a selector, not a query language, and an empty list is a 400, not "everything".

field is one of the product columns sku, kind, enabled, family_id, parent_id, or attribute:<code>:

Operators
eq, neqEquality.
gt, gte, lt, lteOrder — numerically for a number, as text for a string.
inMembership. value is a non-empty array, at most 200 entries, all the same type.
contains, starts_with, ends_withSubstring. value is a non-empty string.
is_empty, is_not_emptyPresence. Takes no value.
A condition addresses the common bucket only. A value held per locale or per channel has no single answer for a rule to test, so locale- and channel-scoped attributes are not supported here.

Preview before you store

POST /v1/products/categories/{category_id}/rules/preview dry-runs the rule in the request body against the live catalog and writes nothing — this is what a "matches N products" indicator reads while an operator edits.

Request
curl -X POST "https://api.revenexx.com/v1/products/categories/{category_id}/rules/preview" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{"rule_match":"all","conditions":[{"field":"attribute:material","operator":"eq","value":"steel"}]}'

It answers a count plus a sample of up to ten products. Counting is delegated to the database rather than enumerated, so a rule compiling to one query gets an exact count whatever its size. A rule needing several queries — rule_match: "any", or a repeated field such as a range — is combined in the app and stops at cap ids. Check capped before presenting count as a total. Soft-deleted products are excluded.

Recompute is chunked — drive it in a loop

POST /v1/products/categories/{category_id}/rules/recompute syncs one category's rule memberships to what its stored rule selects today. It evaluates categories.rules, not the request body. It inserts newly matching products as source: "rule" and deletes rule rows that no longer match, then stamps rules_computed_at.

A large category does not finish in one call. The run stops when its wall-clock budget is spent and answers done: false with a cursor to send back. Loop until done is true. A half-finished pass leaves rules_computed_at untouched, so a null value means no pass has ever completed.

POST /v1/products/categories/rules/recompute-all applies the same sync to every category with a non-null rule. It is what the nightly schedule calls, and the call to reach for after a bulk import has changed what the rules select. The whole run shares one budget: a category the budget no longer reaches is reported as skipped and picked up by the next run, and a failing category is reported in its own result entry instead of aborting the run.

Where to go next

Was this page helpful?