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 leaves its variants with a null parent rather than deleting them. |
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. |
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 an import cannot publish twenty thousand unfinished products the moment it lands, and a product naming no family gets the tenant's default_product_family. An explicit value in the body wins over both.
The variant hierarchy
simplestands on its own.modelcarries the values its variants share and is never sold itself.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 read-only on the model and set on each variant.
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_DE": { "name": "Akku-Bohrschrauber" } },
"channel_specific": { "b2b": { "minimum_order_quantity": 6 } },
"channel_locale_specific": { "b2b": { "de_DE": { "description": "…" } } }
}
| Bucket | Written when |
|---|---|
common | Neither localizable nor scopable — one value, full stop. |
locale_specific | localizable — one value per language tag. |
channel_specific | scopable — one value per channel. |
channel_locale_specific | Both — one value per channel and language tag. |
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 de / en / de_DE / en_US locale buckets, common.manufacturer_aid, and finally the SKU.
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.
Soft-deleted products are excluded from the grid, unlike GET /v1/products.
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. Answers 201 with the stored row. |
PUT /v1/products/{id} | Update. |
DELETE /v1/products/{id} | Delete. |
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. |
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.