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 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_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. |
GET /v1/products/categories also filters on these, each as an exact match:
active:trueunless the category was retired (false). A list returns both unless you filter, so use?active=truefor 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 finishedrecompute-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
| Operation | What 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.
| Field | Meaning |
|---|---|
product_id | Trashing the product leaves the membership in place but inactive; deleting the product for good 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. |
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:
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.
| Operation | What 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_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, 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. 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.
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}/restorebrings the category back from the trash together with its whole subtree, which went with it (the records that share itstrash_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 inrestored. - Refused with
409.not_in_trashwhen the category is not in the trash.parent_in_trashwhen the record it belongs to is in the trash in another group;details.restorenames the call that brings that one back first. - Refused with
404when no category of this tenant has that id. A category of another tenant gets the same404, not a403. - Empty the trash.
POST /v1/products/categories/trash/emptydeletes the trashed categories for good, oldest first, up to 200 per call, each through the same checks as a secondDELETE. - A record a blocker holds is listed in
blockedwith its reason and stays in the trash; the rest of the call goes on. deletedlists what this call removed for good.more: truemeans 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.
| Field | What it holds |
|---|---|
deleted_at | When the category went to the trash; null while it is live. |
deleted_by | Who 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_id | The platform subject of that person, the stable id a privacy request pseudonymises. Null for a scheduled job. |
purge_at | When the daily purge deletes it for good: deleted_at plus the tenant's trash retention (90 days unless the tenant set another). |
trash_batch | The 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_reason | Why 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
- Build categories from rules — the guide: preview, store, recompute, nightly run, event, imported categories.
- 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.