Categories
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
curl "https://api.revenexx.com/v1/products/categories?limit=200" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
| Field | Meaning |
|---|---|
code | Stable identifier. What an import and a storefront join on, and what survives a rename of the label. |
labels | The name a person sees, per language tag. A blank locale falls back to the next filled one. |
parent_id | The category this one hangs under. Null is a root. |
position | Order among siblings under the same parent, ascending. |
path | A materialized position such as tools/power_tools/cordless_drills, kept for importers that carry one. Nothing in this app writes or reads it — parent_id is the structure. |
values | Whatever this catalog keeps on a category beyond the model. The keys belong to the tenant; nothing here reads them. |
rules, rule_match, rules_computed_at | The 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.
| Field | Meaning |
|---|---|
product_id | Deleting the product deletes the membership with it. |
category_id | The category it is filed into. |
position | Sort order of this product inside the category. |
source | manual 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:
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_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, neq | Equality. |
gt, gte, lt, lte | Order — numerically for a number, as text for a string. |
in | Membership. value is a non-empty array, at most 200 entries, all the same type. |
contains, starts_with, ends_with | Substring. value is a non-empty string. |
is_empty, is_not_empty | Presence. Takes no value. |
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.
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.
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
- Products — the rows a rule selects.
- Data model — the attributes a rule addresses by code.
- Segments — the same rule language, applied to organizations.
- Import and export — what to run a recompute after.