Build categories from rules

Fill a category from a rule instead of by hand over the Products API — preview what a rule matches, store it, recompute in a loop until the answer says done, decide whether disabled products count, react to the nightly run, and see how rule categories sit next to categories you imported.

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:

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:

Environment
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 common bucket. A product keeps its attribute values in four buckets: common, per locale, per channel and per channel and locale. A rule reads common only. Scope syntax such as attribute:name:de answers 400. A plain attribute:<code> for an attribute you store per locale or per channel is accepted but matches nothing, so check the preview's count.
  • 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-4711 in an all rule.
  • Numbers match numbers. attribute:diameter gte 19 matches a product that stores 19, 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:

Create the category
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:

Preview
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:

  • count is the number of matching products. It is exact only when capped is false. When capped is true, count is a lower bound: read it as "at least", never as a total. Only a rule that needs several queries can cap: rule_match of any, or one field used twice, such as a range.
  • sample lists up to ten matches (id and sku). Open a few and check they belong.
  • include_disabled says 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):

Store the rule
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.

The recompute reads the stored rule, never a request body. Preview a document, store exactly that document, then recompute. A stored rule that does not compile answers 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:

Recompute in a loop
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, removed and processed count that call only. Your loop adds them up, as above.
  • total and computed_at arrive on the last call. Both are null while done is false.
  • cursor is null when done. 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": null explicitly 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: false is not an error. The call fell back to slower row-by-row inserts.

Check the result by counting the rule memberships:

Count the rule members
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.

A rule you broaden while a pass is unfinished picks up its new matches on the next full pass, not the one in flight. If you change a rule mid-loop, let the loop finish and run one more.

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 valueA rule selects
true (the default)Enabled and disabled products
falseOnly 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:

Read the setting
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_at at null and 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-all yourself and read error in 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:

FieldMeaning
category_id, category_codeThe category that moved.
added, removedRule memberships inserted and deleted by this call.
total_membersMembers afterwards, hand-picked and rule together.
rule_match, rule_hashHow 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_atdone 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: false on an unfinished call. A final done: true event 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 with source=manual rather than assuming.
  • You can add a rule to an imported category. The imported links stay. The rule adds rule memberships around them, and a product that already has a manual link 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 rule memberships 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 rules value 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

SymptomLikely 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 rulesStep 3 did not store a rule. Read the category and check rules.
The category is empty after storing the ruleNothing has recomputed yet. Run step 4.
count looks low and capped is trueThe rule needs several queries and stopped at the ceiling the answer's cap names. Treat count as a minimum.
rules_computed_at stays nullA 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 backIt was a rule membership and still matches. Change the rule instead.
A numeric condition matches nothingThe attribute stores its numbers as text. A rule compares a number only with a stored number.
422 from recompute-allThe tenant holds more than 100,000 categories, more than one run reads. It refuses rather than reconcile a partial list.

Where to go next

Was this page helpful?