Build categories from rules
A catalog of twenty thousand articles cannot be filed by hand. At the end of this guide a category selects its own products: you have tested a rule against the live catalog without saving anything, stored it, run the recompute until it finished, and you know what the nightly run and the category.recomputed event do from then on. You also know what happens when the same tree was imported from a supplier's BMECAT file.
This is for integrators and agencies who load or maintain a customer's catalog. You need:
- A tenant and its slug, sent on every call in
X-Revenexx-Tenant(find your tenant slug). - The Products app installed, with some products in it. Import a BMECAT catalog is one way to get them; Model a catalog explains the attributes a rule reads.
- An API key with the
products.*scope (which scope covers which app). Reading the app settings in step 5 has no documented scope (as of 2026-10), so a development key with*covers it.
New to the platform? Prerequisites covers Cockpit, installing an app and a test tenant. Every call goes through the API gateway. The calls use curl and jq in one terminal session, bash or zsh:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
The Categories reference page lists every field and operator. This guide gives the order to do things in, and covers what that page does not: the nightly run, the event, the disabled-products setting and the interplay with an import.
Decide whether a rule fits
A rule is a selector over one product's own data: a column (sku, kind, enabled, family_id, parent_id) or an attribute (attribute:<code>). Use one when membership follows from what the product is, such as "every steel product that is not discontinued".
Keep a category hand-picked when membership is an editorial choice, or depends on something a rule cannot see:
- Only the
commonbucket. A product keeps its attribute values in four buckets:common, per locale, per channel and per channel and locale. A rule readscommononly. Scope syntax such asattribute:name:deanswers400. A plainattribute:<code>for an attribute you store per locale or per channel is accepted but matches nothing, so check the preview'scount. - No exclusions by hand. A product that matches stays in. Removing its rule membership by hand is undone by the next recompute. To keep a product out, change the rule, for example with a condition such as
sku neq ACME-4711in anallrule. - Numbers match numbers.
attribute:diameter gte 19matches a product that stores19, not one that stores the text"19".
1. Create the category
A rule is previewed against a category id, so the category comes first. Create it hand-picked, with no rule yet:
CATEGORY_ID=$(curl -s -X POST https://api.revenexx.com/v1/products/categories \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code":"steel-fasteners","labels":{"en":"Steel fasteners","de":"Stahl-Befestiger"}}' | jq -r .id)
echo "$CATEGORY_ID"
code is unique per tenant and is what an import and a storefront join on.
2. Preview the rule
The preview evaluates the rule in the request body against the live catalog and writes nothing. Run it as often as you like while you refine the rule:
curl -s -X POST "https://api.revenexx.com/v1/products/categories/$CATEGORY_ID/rules/preview" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rule_match": "all",
"conditions": [
{ "field": "attribute:material", "operator": "in", "value": ["steel", "stainless"] },
{ "field": "attribute:discontinued", "operator": "is_empty" }
]
}' | jq '{count, capped, cap, include_disabled, sample}'
Read four things before you save:
countis the number of matching products. It is exact only whencappedisfalse. Whencappedistrue,countis a lower bound: read it as "at least", never as a total. Only a rule that needs several queries can cap:rule_matchofany, or one field used twice, such as a range.samplelists up to ten matches (idandsku). Open a few and check they belong.include_disabledsays whether disabled products were allowed to match. Step 5 explains the setting behind it.- Soft-deleted products never match.
A rule is 1 to 25 conditions. An empty list is a 400, not "everything". A bad field or operator answers 400 and names the failing condition by position, so you find the typo before anything is stored.
3. Store the rule
Store the rule on the category. rules holds the conditions, and rule_match is a field of its own next to it (all joins the conditions with AND, any with OR, and all is the default):
curl -s -X PUT "https://api.revenexx.com/v1/products/categories/$CATEGORY_ID" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rule_match": "all",
"rules": {
"conditions": [
{ "field": "attribute:material", "operator": "in", "value": ["steel", "stainless"] },
{ "field": "attribute:discontinued", "operator": "is_empty" }
]
}
}' | jq '{code, rule_match, rules_computed_at}'
Storing a rule files nothing. rules_computed_at stays null until a recompute has finished once. Memberships appear only when you recompute (step 4) or the nightly run does.
400 at recompute, so preview first rather than let the nightly run find the mistake.4. Recompute until it says done
A recompute makes the category's rule memberships match what the stored rule selects now: it adds the products that match and removes rule memberships that no longer do. A large category does not finish in one call. Each call works until its time budget is spent, then answers done: false with a cursor. You send the cursor back and stop when done is true:
URL="https://api.revenexx.com/v1/products/categories/$CATEGORY_ID/rules/recompute"
BODY='{}'
ADDED=0; REMOVED=0
while true; do
RES=$(curl -sf -X POST "$URL" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" -d "$BODY") || { echo "recompute call failed; stopping" >&2; break; }
DONE=$(echo "$RES" | jq -r '.done')
[ "$DONE" = "true" ] || [ "$DONE" = "false" ] || { echo "unexpected answer: $RES" >&2; break; }
ADDED=$((ADDED + $(echo "$RES" | jq '.added')))
REMOVED=$((REMOVED + $(echo "$RES" | jq '.removed')))
[ "$DONE" = "true" ] && break
BODY=$(echo "$RES" | jq -c '{cursor: .cursor}')
done
echo "$RES" | jq '{done, total, computed_at}'
echo "added $ADDED, removed $REMOVED"
The loop stops on any failed call instead of carrying on: a 502 or 429 has no cursor, and sending "cursor": null would restart the pass from the beginning. After a failure, wait and start again with an empty body, which resumes where the pass stopped.
How to read the answers:
added,removedandprocessedcount that call only. Your loop adds them up, as above.totalandcomputed_atarrive on the last call. Both arenullwhiledoneisfalse.cursorisnullwhendone. Never invent one.- An empty body is the safe default. It resumes a pass that is unfinished and starts a fresh one when the last pass completed. Sending
"cursor": nullexplicitly always restarts from the beginning. - Replaying is safe. A repeated cursor does not insert a membership twice. Hand-picked memberships are never inserted over and never deleted.
batched: falseis not an error. The call fell back to slower row-by-row inserts.
Check the result by counting the rule memberships:
curl -s "https://api.revenexx.com/v1/products/product_categories?category_id=$CATEGORY_ID&source=rule&limit=1" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '.page.total'
It should be close to total from the last call. It is lower when matching products already hold a hand-picked link to the category (they get no second row), and it drifts if products changed in between. Use source=manual for the hand-picked ones.
To refresh every ruled category at once, call POST /v1/products/categories/rules/recompute-all with an empty JSON body. All categories share one time budget. The answer lists each category under categories: one the budget did not reach is skipped and the next run starts with it, and one that failed carries its own error and status without stopping the rest. When done is false, call it again. Run it after a bulk import, because an import changes what the rules select.
5. Decide whether disabled products count
By default a rule excludes only soft-deleted products, so a product that is switched off (enabled false) is still filed. The Products app setting rules_include_disabled controls this for the whole tenant:
| Setting value | A rule selects |
|---|---|
true (the default) | Enabled and disabled products |
false | Only enabled products |
With the setting off, a condition of your own on enabled wins for the query it sits in: a rule that says enabled eq false selects disabled products. A rule with rule_match of any, or one that repeats a field, is evaluated as several queries, and the others still require enabled. To exclude disabled products from one all category without touching the setting, add { "field": "enabled", "operator": "eq", "value": true } to that rule. Do not add it to an any rule, where it would select every enabled product.
An operator changes the setting in Cockpit's settings form for the Products app. The API cannot write it. You can read the value in force:
curl -s "https://api.revenexx.com/v1/settings/apps/products" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq '.settings.rules_include_disabled'
Preview and recompute both obey it, so the preview's include_disabled and count agree with what the recompute files. Changing the setting moves nothing by itself. Recompute afterwards, or wait for the nightly run. Turning it off removes the rule memberships of disabled products on that pass.
6. Let the nightly run keep it current
You do not have to schedule anything. The Products app runs the same sync as recompute-all every day at 03:00 UTC (04:00 in Berlin in winter, 05:00 in summer). A product edit between runs shows up the next morning, not instantly. Call the recompute yourself when it must be sooner.
Two things to know about the nightly run:
- It makes one pass under one budget shared by all ruled categories, and it never loops. A category that does not finish keeps
rules_computed_atatnulland continues the next night, so a very large category converges over several nights. Drive your own loop when you cannot wait. - A failing rule does not stop the others. To find it, call
recompute-allyourself and readerrorin that category's entry.
When a recompute moves memberships, the app publishes category.recomputed under the revenexx.products. prefix, with the category id as the topic id. Subscribe to it to refresh a storefront cache or a search index (event catalog, consuming events). The payload carries:
| Field | Meaning |
|---|---|
category_id, category_code | The category that moved. |
added, removed | Rule memberships inserted and deleted by this call. |
total_members | Members afterwards, hand-picked and rule together. |
rule_match, rule_hash | How the conditions combined, and a fingerprint of the rule. A changed hash between two events means somebody edited the rule, not that the data changed. |
done, computed_at | done is false when the call is not the last of its pass. |
Three consequences for your handler:
- A recompute that moves nothing publishes nothing. Silence means a settled category, not a missed run. Do not alarm on a missing nightly event.
- A pass that takes several calls publishes once per call that moved rows, with
done: falseon an unfinished call. A finaldone: trueevent does not follow when the last call moved nothing, so do not wait for one. Treat every event as "membership changed". It carries counts, not product ids, so re-read the category's members. - Write for duplicates, reordering and loss. Delivery to an App handler is not settled (as of 2026-10), and webhook delivery is at least once. Key your handler on the event
id, and keep a periodic reconcile.
How rule categories and imported categories coexist
A BMECAT import can create categories and fill them: the supplier's group tree becomes categories (the group id becomes code) and the article-to-group map becomes memberships. It does so only when the import profile maps those entities, which the BMECAT guide does in its step 5.
Both kinds meet in one table, product_categories, and its source column tells them apart:
- Imported categories arrive without a rule, so recompute and the nightly run skip them until you add one.
- Memberships you write or import are
manual, the column default. A recompute never inserts over them and never deletes them. After your first import, check a sample withsource=manualrather than assuming. - You can add a rule to an imported category. The imported links stay. The rule adds
rulememberships around them, and a product that already has amanuallink does not get a second row. - The imported links do not follow the rule. If the rule would not select a product the import filed there, the product stays. Only
rulememberships are removed when a product stops matching.
Two things are not confirmed (as of 2026-10), so test them on a copy of your data before you rely on them:
- Whether a re-import removes a link the feed no longer lists.
- Whether a re-import that rewrites a category row leaves the
rulesvalue you set on it alone. If not, run your rule update again after the import.
After an import, run recompute-all, as Import and export also says. The import changed which products exist and what they hold, so the rules now select something different.
Troubleshooting
| Symptom | Likely cause |
|---|---|
400 on preview or recompute, naming conditions[n] | The condition at that position is malformed, or uses scope syntax such as attribute:name:de. Only attribute:<code> in the common bucket is supported. |
400 on recompute, saying the category has no rules | Step 3 did not store a rule. Read the category and check rules. |
| The category is empty after storing the rule | Nothing has recomputed yet. Run step 4. |
count looks low and capped is true | The rule needs several queries and stopped at the ceiling the answer's cap names. Treat count as a minimum. |
rules_computed_at stays null | A pass never finished. Run the loop to done: true, or call recompute-all and look for skipped entries. |
| A product you removed by hand is back | It was a rule membership and still matches. Change the rule instead. |
| A numeric condition matches nothing | The attribute stores its numbers as text. A rule compares a number only with a stored number. |
422 from recompute-all | The tenant holds more than 100,000 categories, more than one run reads. It refuses rather than reconcile a partial list. |
Where to go next
- Categories: the tree, membership and the full operator table.
- Model a catalog: the attributes a rule addresses.
- Import a BMECAT catalog: the other way categories arrive.
- Settings: reading an app's effective settings.
- Event catalog: every event you can subscribe to.