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 locale key under the tenant's write key. A blank locale falls back along the policy's read order. See Languages and locale codes.
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 it — parent_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.

GET /v1/products/categories also filters on these, each as an exact match:

  • active: true unless the category was retired (false). A list returns both unless you filter, so use ?active=true for a picker. Records that already carry a retired category keep it; a write cannot newly assign one (409 entry_inactive).
  • path_label: the path as names from the root down, joined by ›. Read-only.
  • members_direct: how many live products are filed straight into the category, by hand or by rule.
  • members_total: how many distinct live products are in the category or any category below it (a product filed twice in the subtree counts once).
  • Both counts leave out products in the trash, are null until the first count, and are read-only.
  • counts_computed_at: when the two counts were last made, by the nightly run or a finished recompute-all. A count older than a day is stale.

Timestamps match exactly, with no ranges.

Deleting a parent sends its whole subtree to the trash with it; restoring the parent brings the subtree back.

Read, change or delete one category

OperationWhat it does
GET /v1/products/categories/{id}Reads one category, the whole row as stored.
PUT /v1/products/categories/{id}Changes the named columns. A code already taken answers 409, and so does moving it under an inactive parent (entry_inactive).
DELETE /v1/products/categories/{id}Moves the category and its subtree to the trash. Called on one already there, deletes it for good, which a storefront contact may not do (403). See Trash and restore.

Deleting for good is refused with 409 while an active promotion whose condition names the category holds it. An id no category of this tenant carries answers 404.

Membership

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

FieldMeaning
product_idTrashing the product leaves the membership in place but inactive; deleting the product for good 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.

Assign a product to a category with POST /v1/products/product_categories. product_id and category_id are required; position and source are optional. It answers 201 with the stored row. A second membership of the same product and category answers 409, and so does an inactive category (entry_inactive).

source is manual (the default, a hand-picked membership) or rule. A caller may send rule by hand. The row is then stored as a rule membership, and in a category with a rule the next recompute removes it again unless the rule matches the product.

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".

Read, change or delete one membership

Each row is one membership: this product is filed in this category. GET returns the row as stored. PUT is a partial patch: only the named columns change, and a body that names no column answers 400. DELETE is a hard delete, not a trash move: the membership row is gone and the product and the category are not touched. An unknown id answers 404, and so does another tenant's id.

OperationWhat it does
GET /v1/products/product_categories/{id}Reads one membership, with its source.
PUT /v1/products/product_categories/{id}Changes the named columns. A clash on product_id + category_id answers 409, and so does moving it to an inactive category (entry_inactive).
DELETE /v1/products/product_categories/{id}Removes the membership. Nothing else changes.

Deleting a source: "rule" membership lasts only until the next recompute while the category is active and the product still matches the rule, because a recompute inserts newly matching products again. Change the rule instead. PUT can also set source: a recompute only inserts and deletes rule rows, so manual keeps a membership the rule would drop.

Rule-driven categories

Set rules and the category selects its own members. Null means hand-picked. For a walk-through in order, see Build categories from rules.

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

That is the body of a category create or update. rule_match is a column of its own beside rules, not a key inside it. The preview request takes both at the top level (rule_match and conditions). 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. So are disabled ones when the tenant has turned the Products setting rules_include_disabled off; it is on by default, and the answer's include_disabled says which applied.

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 retired category answers 422 category_inactive.

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 that is not retired; retired categories are skipped. It is what the nightly schedule calls (every day at 03:00 UTC, and it publishes a category.recomputed event for each category that moved), 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.

Trash and restore

Deleting a category sends it to the trash. A second delete of the same category deletes it for good, and so does emptying the trash. The platform also deletes trashed records that outlive the tenant's trash retention (90 days unless the tenant set another), except one that something still blocks.

The first delete fires category.trashed and a restore category.restored. category.deleted fires only once the record is deleted for good. See Deletes and the trash.

  • Restore. POST /v1/products/categories/{id}/restore brings the category back from the trash together with its whole subtree, which went with it (the records that share its trash_batch). A record trashed on its own before stays in the trash. Everything that pointed at the category acts again as it did, because nothing was changed when it went. Restoring needs the same right as deleting. The answer lists every record that came back in restored.
  • Refused with 409. not_in_trash when the category is not in the trash. parent_in_trash when the record it belongs to is in the trash in another group; details.restore names the call that brings that one back first.
  • Refused with 404 when no category of this tenant has that id. A category of another tenant gets the same 404, not a 403.
  • Empty the trash. POST /v1/products/categories/trash/empty deletes the trashed categories for good, oldest first, up to 200 per call, each through the same checks as a second DELETE.
  • A record a blocker holds is listed in blocked with its reason and stays in the trash; the rest of the call goes on.
  • deleted lists what this call removed for good. more: true means the call stopped at its limit and the trash may hold more: call it again.
  • A storefront contact gets 403: emptying the trash is back-office work.

Reading and filtering the trash. GET /v1/products/categories leaves trashed categories out unless the call asks with trashed: only returns the trash alone, include returns the live and the trashed categories together. Each category carries the fields below, and each one is also an exact-match filter on the list call.

FieldWhat it holds
deleted_atWhen the category went to the trash; null while it is live.
deleted_byWho moved it there: an e-mail, or system for a scheduled job. A name for the trash view, not an identity to check.
deleted_by_idThe platform subject of that person, the stable id a privacy request pseudonymises. Null for a scheduled job.
purge_atWhen the daily purge deletes it for good: deleted_at plus the tenant's trash retention (90 days unless the tenant set another).
trash_batchThe trash action it went with. A delete stamps the category and its subtree with the same value, and a restore brings back exactly that group.
purge_blocked_reasonWhy the daily purge could not delete it for good; null when nothing held it.

The platform sets these fields when a category is deleted and clears them when it is restored, never through an update, so a client does not normally send them in a write.

Where to go next

Was this page helpful?