Products
The products entity is the catalog itself. It is deliberately narrow: five meaningful columns plus one jsonb document that holds everything the merchant modelled.
curl "https://api.revenexx.com/v1/products?limit=20" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
The row
| Column | Meaning |
|---|---|
id | Server-generated uuid. A client never sends one — it reads one back and uses it in later paths. |
sku | The merchant's own article number. Unique per tenant, and the value every integration joins on. A second row with the same sku answers 409 with "code": "unique_violation". |
kind | Where the row sits in the variant hierarchy: simple, model or variant. |
parent_id | The model a variant belongs to. Only a variant carries one. Deleting the model sends its variants to the trash with it; they come back together on restore. |
family_id | Which set of attributes this product has. |
family_variant_id | Which variant structure of that family it follows. |
enabled | Whether the product is live. |
tax_class | The tax class key the prices app resolves a VAT rate from. Free text here — the vocabulary belongs to the app that prices. Markets refuses to delete a tax class that a product still names; see the usage check. |
attribute_values | Every modelled value, in one jsonb document. See below. |
label | The resolved display name, maintained by the database. Read-only. |
completeness | What POST /{id}/completeness last measured. |
sku is the only column a create cannot omit. Two defaults are applied by the app rather than by the column: enabled comes from the tenant's new_products_enabled_by_default setting, so a client creating products through this route cannot publish twenty thousand unfinished products the moment it lands, and a product naming neither a family_id nor a family_variant_id gets the tenant's default_product_family. An explicit value in the body wins over both. A BMECAT import writes its products directly and applies neither default.
The variant hierarchy
simplestands on its own.modelcarries the values its variants share. By convention it is not sold itself. The products API does not enforce that, so filter onkindwherever only sellable rows belong.variantcarries the axis values and points at its model throughparent_id.
Which attributes are the axes is declared per family by family_variants. An attribute named as an axis is set on each variant, not on the model. The attribute schema marks it read-only when you ask with kind=model, which is a hint to a form and not a rule of the API: a write of an axis value to a model is accepted.
attribute_values — four buckets
A product's properties are not columns. They are rows in attributes, selected per family by family_attributes, and their values live under the attribute code inside one of four buckets. The attribute's own localizable and scopable flags decide which:
{
"common": { "net_weight": 2.4, "colour": "black" },
"locale_specific": { "de": { "name": "Akku-Bohrschrauber" } },
"channel_specific": { "b2b": { "minimum_order_quantity": 6 } },
"channel_locale_specific": { "b2b": { "de": { "description": "…" } } }
}
The locale keys here are de because the example tenant keeps text per language. A tenant that keeps text per region writes de-DE. Never write de_DE: use the write key the tenant's locale policy names. See Languages and locale codes.
minimum_order_quantity here is only an example of a custom attribute. The platform stores it and returns it, but nothing in the cart or order flow enforces a minimum order quantity or a quantity step (as of 2026-10), so your storefront has to check it. What the platform does enforce is a ceiling, not a floor: max_items_per_cart and max_quantity_per_line on carts, and max_items_per_order on orders. A payment method's min_order_value is the only enforced minimum. It hides that payment method for a small order and does not reject the order.
| Bucket | Written when |
|---|---|
common | Neither localizable nor scopable — one value, full stop. |
locale_specific | localizable — one value per locale key. |
channel_specific | scopable — one value per channel. |
channel_locale_specific | Both — one value per channel and locale key. |
Reading falls back. A reader takes the most specific bucket carrying the code, then locale, then channel, then common. common is always consulted last and always consulted, because early imports wrote everything there regardless of an attribute's flags — a reader that skips it reports an imported catalog as empty.
Do not re-derive that chain. GET /v1/products/attribute-schema answers, per field, the exact path a value belongs at and the full fallback order.
The value itself is whatever the attribute's type implies: a string, a number, a boolean, an option code for a select (never its label), a list of codes for a multi-select, {"amount": …, "unit": …} for a measure, a list of {"amount": …, "currency": …} for a price, an asset code for media.
{} is a normal state — a product nobody has enriched yet.
label is generated, and tells you when it is missing
label is maintained by the database so a grid of twenty thousand rows can sort and filter on a name without a join. It resolves the first of attribute_values.common.name, common.label, the name in the de-DE, en-US, de and en locale buckets, common.manufacturer_aid, and finally the SKU. Older data stored under de_DE or en_US is still read, after those, but no client should write those keys.
That order is fixed and does not follow the tenant's locale policy, so label is a sort and search helper, not the name to show a buyer. For display, read the name along the policy's read order for the locale you render.
So a value is always present — and a label equal to the SKU means the catalog holds no name for this product. Show it as a missing name, not as a name.
The grid
GET /v1/products answers SKUs and a jsonb blob. GET /v1/products/grid answers the list a person can scan: each row already flattened to its resolved display name, where that name came from, its family code, its stored completeness, and the value of every attribute marked usable_in_grid — no join, no second call.
curl "https://api.revenexx.com/v1/products/grid?q=bohr&enabled=true&limit=50" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..."
Two things to know:
- It filters on
q,kind,enabledandfamily_idand nothing else. An unknown query parameter is refused with400rather than silently dropped.qis a case-insensitive substring of the storedlabel, which falls back to the SKU, so one box finds a product by either. - The response's
filtersarray reports the attributes markedis_filterable— that is what a filter bar should offer. It is not a query surface: filtering by attribute value is not offered by this API, because the values sit inside a four-bucket jsonb document behind a fallback chain.
Trashed products are excluded from the grid.
Two resolvers for other apps
POST /v1/products/batch answers id, sku, tax_class and the resolved display name for a list of ids and/or SKUs in one call. It exists for the app on the other side of a product reference — the prices app holds SKUs and needs a tax class, a feed builder holds ids and needs names — so neither pages through the catalog nor fires a request per line.
curl -X POST https://api.revenexx.com/v1/products/batch \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"skus":["ACME-4711-BLK","ACME-4711-RED"]}'
It answers what it found: an identifier naming nothing is simply absent from items rather than an error. Compare what you sent with what came back if a miss matters.
POST /v1/products/labels answers what is this product called for up to 500 products at once, and reports where each name was found. source: "sku" means the catalog holds no name for it.
Writes
| Route | Does |
|---|---|
POST /v1/products | Create one product. sku is the only required field. Answers 201 with the stored row. To start from another product, see Creating from an existing product. |
PUT /v1/products/{id} | Update. |
DELETE /v1/products/{id} | Move a product to the trash; a second call on a trashed product deletes it for good. |
POST /v1/products/{id}/restore | Restore a product from the trash. See Trash and restore. |
POST /v1/products/trash/empty | Delete the products in the trash for good, up to 200 per call; a blocked one stays. |
POST /v1/products/{id}/family | Assign a family by family_id or family_code, and recompute completeness in the same call. |
POST /v1/products/{id}/categories | File the product into a category by hand. The membership is always source: "manual", so a rule recompute never deletes or shadows it. |
Creating from an existing product
POST /v1/products takes three more keys, all optional:
| Key | Does |
|---|---|
name | String, at least one character; a name of only spaces is ignored. The product's name, stored as the name value under the first of the tenant's label_locales (German, de, when none is set). A shortcut for one value of attribute_values. |
copy_from | UUID of a product to start from. Its family, family variant, manufacturer, tax class, channels, base values (without the GTIN) and attribute values are copied. Image and file values come along, because they are attribute values. Anything the body sends wins, except a null or an empty string, which keeps the copied value; an attribute_values in the body replaces the copied document as a whole. The copy is a standalone product with status draft, like every new product (see Filters on the product list for the statuses): its kind (the default, simple, unless the body sends one) and parent are not copied, and neither are variants or category memberships. enabled is not copied either: as on any create, it comes from the body or the new_products_enabled_by_default setting, so a copy is not switched off the way POST /v1/products/{id}/copy switches it off. POST /v1/products/{id}/copy copies those when its parts asks for them. |
category_id | UUID of a category to file the new product into (see Categories). The membership is source: "manual", as with POST /v1/products/{id}/categories. The category must exist, be live and be active. |
name is applied after copy_from, so it replaces the copied name in that locale.
curl -X POST https://api.revenexx.com/v1/products \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "Content-Type: application/json" \
-d '{"sku":"ACME-4712-18V","copy_from":"<PRODUCT_ID>","category_id":"<CATEGORY_ID>","name":"Akku-Bohrschrauber 18V"}'
404:copy_fromorcategory_idnames nothing. Nothing is created.409,in_trash: thecopy_fromproduct or the category is in the trash, or a product in the trash holds thesku.409,entry_inactive: the category is inactive, or the new product would carry an inactive family, family variant, manufacturer, option or reference record, copied ones included.
The checks above run before the product is written, so a refused call creates no product. The call is not atomic past that point: the product is written first and the category membership second. If filing it into the category fails, the call answers with an error but the product exists without that category; find it by its sku and file it with POST /v1/products/{id}/categories.
Per-product routes and the setup checklist
The four POST routes each work on one product; a bulk action in the app calls one of them once per product in the selection. All four answer 409 in_trash for a trashed product. GET /v1/products/setup is app-wide and takes no product id.
| Route | Body |
|---|---|
GET /v1/products/setup | None. Answers 200 with the setup checklist. |
POST /v1/products/{id}/copy | sku string, required: the copy's own SKU. parts array, optional: any of attributes, variants, images, categories. |
POST /v1/products/{id}/channels | channels array of strings, required. mode string, optional: add (the default), remove or replace. |
POST /v1/products/{id}/manufacturer | manufacturer_id string, required. |
POST /v1/products/{id}/attribute-value | attribute string, required: the attribute code. value, required (400 if missing; null stores null under the code, the key is not removed). locale string, optional: a language key the tenant writes, or all (the default). |
- Setup checklist. Answers
200with{ app, steps, done, total, complete }. Each step is{ key, done, kind, optional }, wherekindisauto(the app can check it) ormanual(the app cannot check it, so it always reportsdone: false). A step is done when what it names exists: a live row of a table, or a setting with a value.done,totalandcompletecount required steps only; optional steps are listed but do not count. - Copy. Creates a copy under the new SKU and answers
201with the new row andcopied_parts. The name is always copied. The copy starts as a switched-off draft without a GTIN, release or completeness.attributescopies every value except files and images,imagescopies the file and image values,variantscopies each variant with its attribute values (even withoutattributesinparts), andcategoriescopies the hand-picked memberships. A variant's SKU is derived: the new SKU replaces the old prefix, otherwise it is<new>-<n>. Movement data and prices are not copied. Errors:400 sku_required;400 invalid_partsfor apartsvalue outside the four;409 sku_takenwhen the SKU or a derived variant SKU is taken;409 in_trashwhen a product holding the SKU is in the trash. There is no transaction across rows: if a later step fails, what this call created is deleted again. - Channels. Adds, removes or replaces the product's channel codes. The codes are stored as sent, trimmed and once each; they are not checked against the channels app. Errors:
400 invalid_channelswhenchannelsis not a list of strings;400 invalid_modewhenmodeis notadd,removeorreplace. - Manufacturer. Links the product to the manufacturer and copies its GPSR state onto the product. It does not create a manufacturer. It answers
404for an unknown manufacturer,409 in_trashfor a trashed one, and409 entry_inactivefor an inactive one the product does not already carry. A plainPUT /v1/products/{id}withmanufacturer_idalso copies the GPSR state, but it does not answer404for an unknown manufacturer. - Attribute value. Sets one attribute on one product and leaves the rest of its values as they are. With
localethe value goes under that language; withallor nolocaleit goes into the common value, the same in every language. Errors:400whenattributeorvalueis missing;404for an unknown product attribute code;422for a locale key the tenant does not write;409 entry_inactivewhen the value names an inactive option or record.
Filters on the product list
GET /v1/products also filters on these, each as an exact match:
status:draft,in_progress,in_review,released,phase_outorblocked.draftis the default on create. It sits besideenabled, which a shop still reads; the app does not derive one from the other.GET /v1/products/vocabularies/product-statusserves the words.status_changed_at: set on create and on every status change. A write that names it has it dropped.channels: the whole list of channel codes, written in braces such as{webshop-de,punchout}. Order matters, and an empty list means no channel.channel_release: the whole per-channel release object, keyed by channel code. Null when the product is released as a whole.base_values: the whole commercial basics object (unit,gtinand the rest).manufacturer_gpsr_complete:trueorfalse, copied from the manufacturer. Null without a manufacturer. Read-only.
Timestamps match exactly, with no ranges. channel_release and base_values compare the whole JSON document, so null cannot be matched; a value that is not valid JSON answers 400 invalid_value.
Trash and restore
Deleting a product sends it to the trash. A second delete of the same product 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 product.trashed and a restore product.restored. product.deleted fires only once the record is deleted for good. See Deletes and the trash.
- Restore.
POST /v1/products/{id}/restorebrings the product back from the trash together with the variants of a model that 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 product 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 product 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 product of this tenant has that id. A product of another tenant gets the same404, not a403. - Empty the trash.
POST /v1/products/trash/emptydeletes the trashed products 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 leaves trashed products out unless the call asks with trashed: only returns the trash alone, include returns the live and the trashed products together. Each product carries the fields below, and each one is also an exact-match filter on the list call. deleted_at and purge_at match only an exact timestamp; there is no range filter, so to see what was trashed in a period, sort with order and page.
| Field | What it holds |
|---|---|
deleted_at | When the product 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 product and what went with it 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 product is deleted and clears them when it is restored, never through an update, so a client does not normally send them in a write.
The tax-class usage check
curl "https://api.revenexx.com/v1/products/tax-classes/reduced/usage" \
-H "X-Revenexx-Tenant: <TENANT_SLUG>" \
-H "X-Revenexx-Api-Key: rvxk_..." \
-H "X-Revenexx-Market: at"
A product names its tax class by code in tax_class, and a variant is a product row of its own, so it carries its own tax_class. Markets is the source of record for the rate, and it asks this route before it deletes a tax class: a count above zero stops the delete. Products in the trash count too, because a restore brings them back naming the class.
The answer carries in_use, count (capped at 1000), trashed (how many of those are in the trash) and samples (the first 20 by SKU, with id, code and name). count and samples are the field names every app's usage answer shares. A tax class is unique per market, so send X-Revenexx-Market to count only the products visible in that market: those assigned to it, plus the ones assigned to no market. A product has no market column; its market assignment is a scope (see Market-scopeable entities). Without the header every product of the tenant counts. Every code answers: one nobody points at is in_use: false, never a 404.
Where to go next
- Data model — the attributes, families and variant structures this row points at.
- Completeness —
attribute-schemaand the completeness measure. - Categories — the tree and rule-driven membership.
- Import and export — loading a catalog in volume.
- Languages and locale codes — which key a translated value is stored under.