Data model

The catalog's shape is data, not schema — attributes, attribute groups and options, families, family attributes, variant structures and measurement families.

Adding a property to a product on revenexx is not a migration. It is a row in attributes and a row in family_attributes. Six entities describe the whole shape of a tenant's catalog, and every one of them is ordinary tenant data a merchant can change.

EntityBase pathAnswers
attribute_groups/v1/products/attribute_groupsWhich section of a form a field appears in
attributes/v1/products/attributesWhat properties exist at all
attribute_options/v1/products/attribute_optionsThe permitted values of a select
families/v1/products/familiesWhich set of attributes a product has
family_attributes/v1/products/family_attributesThe link, plus per-family required and order
family_variants/v1/products/family_variantsHow a family splits into variants
measurement_families/v1/products/measurement_familiesThe units a measure attribute offers

The active flag works the same on these six entities (attribute_groups, attributes, attribute_options, families, family_variants and measurement_families):

  • Write. active is an optional boolean on POST and PUT, default true. Send false to make an entry inactive. Anything that is not a boolean answers 400 invalid_active.
  • Read. A list returns active and inactive entries unless you filter. Use ?active=true for a picker.
  • Existing records. Records that already carry an inactive entry keep it. A write cannot newly assign one (409 entry_inactive).

Attributes

Request
curl "https://api.revenexx.com/v1/products/attributes?limit=100" \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..."
FieldMeaning
codeStable identifier, and the key the value is stored under inside attribute_values. Also how a category rule addresses it, as attribute:<code>.
typeWhich editor the value asks for — text, select, metric, price, asset_collection, reference_entity, …
localizableOne value per locale, under locale_specific.<locale>.<code>.
scopableOne value per channel, under channel_specific.<channel>.<code>. Both flags together means channel_locale_specific.
entity_typeWhich kind of record carries it: product, reference_entity, asset, category.
entity_refNarrows entity_type to one reference entity or asset family, by code. Null for a plain product attribute.
group_idThe attribute_groups row it is filed under — the form section. Null renders after every named section.
positionOrder inside the group. A family may override it.
labelsThe field label per locale key, under the tenant's write key. A reader walks the policy's read order and shows the code when nothing is found.
validationLimits, as a flat object. The seven keys a client can act on: min, max, min_length, max_length, pattern, min_items, max_items.
configType-specific settings. The app reads units (a measure's unit list) and reference_entity (which entity a reference attribute draws options from).
is_filterableOffer this attribute as a filter in a product list. GET /v1/products/grid reports exactly these in its filters array.
usable_in_gridShow it as a column in the grid. GET /v1/products/grid returns a column definition and a per-row value for exactly these.
is_uniqueDeclares the value identifies the product — an EAN, a manufacturer part number. Metadata only: no database index enforces it, because the value lives inside jsonb.

type and entity_type deliberately carry no fixed value list. A tenant or integrator can add a type; GET /v1/products/attribute-schema maps an unknown one onto a text field rather than refusing to answer.

Attribute groups

A group is a section of the product form, not just a label. code is what a resolved field carries as its group, labels is the heading a person sees, and position orders the sections ascending.

Attribute options

The permitted values of a select or multi-select.

Option
{
  "attribute_id": "…",
  "code": "matte_black",
  "labels": { "en": "Matte black", "de": "Mattschwarz" },
  "position": 20,
  "swatch": { "hex": "#1b1b1b" }
}

Label keys follow the tenant's locale policy: de and en in a tenant that keeps text per language, as in these examples, de-DE and en-GB in one that keeps text per region. See Languages and locale codes.

code is what gets stored in the product's attribute_values — never the label. Two tenants may label the same code differently. Deleting the attribute deletes its options with it. Options that tie on position keep whatever order the database returned, so give every option a position if the order matters.

Families

A family answers which attributes does a product of this kind have.

FieldMeaning
codeStable identifier, and what GET /v1/products/attribute-schema?family_code= resolves.
labelsWhat the family is called, per locale key.
label_attributeWhich attribute code carries the display name of a product in this family. Null falls back to the default_label_attribute setting, then to the conventional name.
image_attributeWhich attribute code carries the main image — what a grid thumbnail and a picker read.

A product's name being per-family is the reason POST /v1/products/labels exists: no plain read can answer what a product is called.

Family attributes

One row per (family, attribute). This is the link that makes an attribute part of a family's form.

FieldMeaning
is_requiredThe attribute has to carry a value for a product of this family to count as complete. POST /v1/products/{id}/completeness measures exactly these and nothing else.
positionThe family's own ordering, overriding the attribute's default.
required_channelsNarrows is_required to named channels.
required_channels: null or [] means required everywhere, not nowhere. That is how every required link in the wild is stored. Reading an empty list as "nowhere" reports a fully configured family as demanding nothing.

Deleting either side deletes the link.

Family variants

How a family splits — not which product splits. A family may carry several structures, and a product names the one it follows through family_variant_id.

axes names the attribute codes the split happens on, and two shapes are in the wild; both are read:

axes — flat
{ "code": "shoes_by_colour_size", "axes": ["colour", "size"] }
axes — levelled
{
  "code": "shoes_by_colour_size",
  "axes": [
    { "level": 1, "axes": ["colour"] },
    { "level": 2, "axes": ["size"] }
  ]
}

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.

Measurement families

The unit set a measure attribute offers.

Measurement family
{
  "code": "weight",
  "standard_unit": "KILOGRAM",
  "units": [
    { "code": "KILOGRAM", "symbol": "kg", "convert_factor": 1 },
    { "code": "GRAM", "symbol": "g", "convert_factor": 0.001 }
  ],
  "labels": { "en": "Weight" }
}

convert_factor multiplies a value into standard_unit, so a gram is 0.001 kilograms. Every value is converted to the standard unit before it is compared or sorted. symbol is what a form prints next to the number.

List and create

Every list is paged with limit and offset, and reports page.total. Every column of the entity is an exact-match query parameter, order sorts by one column, and a query key that is not a column is refused with 400 (unknown_filter). The API reference still says that such a key is dropped; the API refuses it, as described here.

OperationWhat it does
GET /v1/products/attributesLists attributes.
POST /v1/products/attributesCreates an attribute. code and type are required. A clash on entity_type + entity_ref + code answers 409; two rows with an unset entity_ref never clash.
GET /v1/products/attribute_groupsLists the form sections.
POST /v1/products/attribute_groupsCreates a section. code is required. A code already taken answers 409.
GET /v1/products/attribute_optionsLists attribute options.
POST /v1/products/attribute_optionsCreates an option. attribute_id and code are required. A clash on attribute_id + code answers 409.
GET /v1/products/familiesLists families.
POST /v1/products/familiesCreates a family. code is required. A code already taken answers 409.
GET /v1/products/family_attributesLists the family-attribute links.
POST /v1/products/family_attributesLinks an attribute to a family. family_id and attribute_id are required. A clash on family_id + attribute_id answers 409.
GET /v1/products/family_variantsLists family variants.
POST /v1/products/family_variantsCreates a family variant. family_id and code are required. A code already taken answers 409.
GET /v1/products/measurement_familiesLists measurement families.
POST /v1/products/measurement_familiesCreates a measurement family. code and standard_unit are required. A code already taken answers 409.

Every create answers 201 with the stored row, including its id. A body whose active is not true or false answers 400 (invalid_active), on every entity here but family_attributes.

Read, change or delete one entry

Attribute groups, attributes, attribute options, family attributes, measurement families, families and family variants each have three routes on {id}:

  • GET returns the whole row as stored.
  • PUT is a partial patch: it names only the columns to change, and every column it leaves out keeps its value. A body that names no column answers 400, and so does an active that is not true or false (invalid_active, on every entity here but family_attributes). On an attribute, a family attribute or a family, pointing the row at an inactive entry it did not point at before answers 409 entry_inactive.
  • Locale keys. On every entity here but family_attributes, a locale key in labels that is not one of the tenant's write keys answers 422 (unknown_locale, ambiguous_locale, invalid_locale, or duplicate_locale when two spellings of one key carry different values).
  • DELETE is a hard delete: the row is gone, with no trash and no restore. To take an entry out of use and keep it, set active to false instead.
  • Unknown id. An unknown id answers 404, and so does another tenant's id.

The table below lists every cascade for each entity: a delete changes nothing beyond what its row names. Products store attribute and option codes inside attribute_values, not ids, so a value already stored under a deleted attribute or option stays as it is.

OperationWhat it does
GET /v1/products/attribute_groups/{id}Reads one section.
PUT /v1/products/attribute_groups/{id}Changes the named columns. A code already taken answers 409.
DELETE /v1/products/attribute_groups/{id}Deletes the section. attributes.group_id is set to null; the attributes stay.
GET /v1/products/attributes/{id}Reads one attribute.
PUT /v1/products/attributes/{id}Changes the named columns. A clash on entity_type + entity_ref + code answers 409; two rows with an unset entity_ref never clash.
DELETE /v1/products/attributes/{id}Deletes the attribute, and with it its attribute options and family attributes.
GET /v1/products/attribute_options/{id}Reads one option.
PUT /v1/products/attribute_options/{id}Changes the named columns. A clash on attribute_id + code answers 409.
DELETE /v1/products/attribute_options/{id}Deletes the option. Nothing else changes.
GET /v1/products/family_attributes/{id}Reads one family-attribute link.
PUT /v1/products/family_attributes/{id}Changes the named columns. A clash on family_id + attribute_id answers 409.
DELETE /v1/products/family_attributes/{id}Deletes the link. Nothing else changes.
GET /v1/products/measurement_families/{id}Reads one measurement family.
PUT /v1/products/measurement_families/{id}Changes the named columns. A code already taken answers 409.
DELETE /v1/products/measurement_families/{id}Deletes the family. Nothing else changes.
GET /v1/products/families/{id}Reads one family.
PUT /v1/products/families/{id}Changes the named columns. A code already taken answers 409.
DELETE /v1/products/families/{id}Deletes the family, with its family attributes and family variants. Products of the family stay, with family_id set to null.
GET /v1/products/family_variants/{id}Reads one family variant.
PUT /v1/products/family_variants/{id}Changes the named columns. A code already taken answers 409.
DELETE /v1/products/family_variants/{id}Deletes the family variant. Products that followed it stay, with family_variant_id set to null.

Seed the base model

Request
curl -X POST https://api.revenexx.com/v1/products/defaults \
  -H "X-Revenexx-Tenant: <TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

POST /v1/products/defaults creates the smallest catalog model a tenant can work with, for a tenant that has none: the attributes name (text, localizable), description (textarea, localizable) and images (asset_collection), and the family standard carrying all three, with name as its label attribute and required, and images as its image attribute. Every attribute and family the seed writes carries metadata.seeded_by: "products.defaults", and that mark is how a run tells its own rows from the tenant's. The model is created without a market.

  • Any attribute or family without the mark (one the tenant created): the answer is seeded: false with reason: not_empty, and nothing is written.
  • No attribute or family at all, or only marked ones: the run creates whatever of the model is missing. So a run that stopped half-way is finished by the next one.
  • The whole model already in place: nothing is written, and the answer is seeded: false with reason: already_seeded. That makes the call idempotent.

The answer lists the attribute codes the run created, the family code if it created the family, and how many attributes it assigned.

Read the shape in one call

Six entities is five reads and a join. Do not do that — GET /v1/products/attribute-schema answers one ready-to-render field list, ordered by group then by the family's own ordering, with the storage path and fallback order per field. See Completeness.

Where to go next

Was this page helpful?