Model a catalog with attributes, families and variants

Decide which facts are attributes, which products share a family and where variants split, then build it over the API with a worked example, check how variants list and how completeness reads, and see what an import does when the model was skipped.

A customer sells a cordless drill in two colours and two voltages. Before anything is imported, you decide what that is in the catalog: one product, four products, or one product with four variants. At the end of this guide the drill exists as a model with four variants, each with its own SKU (the article number the customer already uses), a family that says which fields a drill has, and a completeness score you can measure on demand. You will also know what a catalog looks like when the import ran first and the model was skipped, and how to repair it. If you are a merchant who only wants to edit products by hand, use Cockpit, the admin UI at app.revenexx.com, instead; this guide is for loading data over the API.

This is for integrators and agencies who load or migrate a customer's product data. You need:

  • A tenant and its slug. The slug is the tenant's short name, sent on every call in X-Revenexx-Tenant; revenexx tenants list prints the ones your account can reach (Select a tenant; find your tenant slug).
  • The Products app installed on that tenant. Apps are installed from the App Marketplace in Cockpit (Platform overview; check and install an app). If it is missing, the first call in step 1 answers 404, because no installed app serves the path (Errors).
  • An API key with the products.* scope (create an API key; which scope covers which app). It covers every Products operation, writes included. A narrower key needs each operation this guide uses: products.attributes.list, products.attributes.create, products.attribute_options.create, products.families.create, products.family_attributes.create, products.family_variants.create, products.create, products.list, products.get, products.update, products.grid, products.attribute-schema, products.completeness and products.family.assign.
  • The customer's product list in front of you (sizes, colours, units and the like).

New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant.

Every call goes through the API gateway. The calls use curl and jq, keep the ids they return in shell variables, and assume one terminal session from start to finish. They run in bash and in zsh, the default shell on macOS:

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
Statements here about behaviour the API does not validate or enforce can change. A date appears next to a claim only where it is the exception. (as of 2026-10)

Decide the model first

Three questions settle almost everything. Answer them on paper, with the customer, before you create a row:

QuestionIf the answer is…Then it is a…
What facts do we keep about a product (name, colour, weight, EAN)?each factAttribute, with a code and a type
Which products carry the same set of facts?all drills, all cablesFamily, linked to its attributes
Within one family, what makes two products "the same product in another size"?colour and voltageVariant axes, declared on a family variant

Not everything is an attribute. A product has a few fields of its own: sku, kind, parent_id, family_id, family_variant_id, enabled and tax_class. Everything else you keep about it, its name and its EAN included, is an attribute, and so is a picture (an asset_collection attribute holds asset codes). Prices and stock are not on the product at all: the Prices and Inventories apps keep them and refer to the product by SKU or id.

A family covers many products, and a model is one article with its axes. Then the shape of a product follows. Every product has a kind:

  • simple stands alone.
  • model groups its variants and holds the facts you choose to keep in one place. Nothing is inherited: the API never copies or merges a model's values into its variants, so a fact a variant must show or score on has to be on the variant itself (step 6).
  • variant is one sellable combination of one model.

A drill in two colours and two voltages is one model and four variants. These roles are conventions that you keep: nothing in the API stops you selling a model, and nothing checks that a variant points at one (see step 4).

Decide the axes carefully. An attribute named as an axis is set on each variant and not on the model. Make something an axis when the customer sells the product as one article in another colour, size or voltage and the buyer picks it on one page. If the other version needs different fields, it belongs in a different family, so model it as a separate product.

The axes are the part that is hard to change once variants exist. PUT /v1/products/family_variants/<id> changes a family variant's axes, but nothing re-checks the variants you already have: you set each new axis value on each variant yourself (step 7) and find the gaps with the check in step 5.

1. Create the attributes and their options

Work in this order: run the check below, then the create block once, and after a failure part way rerun the check, not the block. A duplicate attribute is accepted silently, so the block must not run twice.

Attribute codes are not deduplicated, so check before you create. A tenant may already have a name attribute, and a second one with the same code is accepted rather than refused. Look each code up first; code is an exact-match filter on the list (Pagination & filtering):

Check for existing attributes
for code in name colour voltage; do
  curl -s "https://api.revenexx.com/v1/products/attributes?code=$code" \
    -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
    -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
    | jq -r '.items[] | [.code, .id, .type] | @tsv'
done

No output means none of the three exists yet. If jq reports Cannot iterate over null instead, run the call without jq and read the error: 404 means the Products app is not installed, 403 that the key lacks the scope. For a code that prints a row, reuse it: set its variable to the printed id (NAME_ID=<id>) and leave its line out of the next block. First check the printed type: name must be text, and colour and voltage must be select. If a code exists with another type, choose a different code and use it in every later step, because changing an attribute's type later leaves stored values in their old bucket (see below). For a reused select, create only the option codes it lacks: an option code that already exists answers 409.

An attribute needs a code and a type. The code is the key the value is stored under in the product, so choose it once. A select also needs its permitted values as options, and an option's code, never its label, is what a product stores:

Create the attributes
# POST a body; print the stored row on 2xx, otherwise print the error body and fail.
create() {
  local out http_code
  out=$(curl -sS -w '\n%{http_code}' -X POST "https://api.revenexx.com/v1/products$1" \
    -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
    -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
    -H "Content-Type: application/json" -d "$2") || return 1
  http_code=${out##*$'\n'}
  out=${out%$'\n'*}
  case $http_code in
    2??) printf '%s\n' "$out" ;;
    *) printf 'create %s failed with HTTP %s: %s\n' "$1" "$http_code" "$out" >&2; return 1 ;;
  esac
}

# The same call, but print only the new id, and fail if there is none.
create_id() {
  local row id
  row=$(create "$@") || return 1
  id=$(printf '%s' "$row" | jq -r '.id // empty')
  if [ -z "$id" ] || [ "$id" = null ]; then
    printf 'create %s returned no id: %s\n' "$1" "$row" >&2
    return 1
  fi
  printf '%s\n' "$id"
}

build_attributes() {
  NAME_ID=$(create_id /attributes '{"code":"name","type":"text","labels":{"en":"Name"}}') || return 1
  COLOUR_ID=$(create_id /attributes '{"code":"colour","type":"select","labels":{"en":"Colour"}}') || return 1
  VOLTAGE_ID=$(create_id /attributes '{"code":"voltage","type":"select","labels":{"en":"Voltage"}}') || return 1

  create /attribute_options "{\"attribute_id\":\"$COLOUR_ID\",\"code\":\"black\",\"labels\":{\"en\":\"Black\"}}" || return 1
  create /attribute_options "{\"attribute_id\":\"$COLOUR_ID\",\"code\":\"red\",\"labels\":{\"en\":\"Red\"}}" || return 1
  create /attribute_options "{\"attribute_id\":\"$VOLTAGE_ID\",\"code\":\"12v\",\"labels\":{\"en\":\"12 V\"}}" || return 1
  create /attribute_options "{\"attribute_id\":\"$VOLTAGE_ID\",\"code\":\"18v\",\"labels\":{\"en\":\"18 V\"}}" || return 1
}
build_attributes

create takes the part of the path after /v1/products: create /attributes posts to /v1/products/attributes, and create "" posts to /v1/products to make a product. Every create answers 201 with the stored row, so .id is the new id. On any other answer the helper prints the status and the error body and fails, and build_attributes stops at that call, so a failed call never leaves a variable set to null. Fix the cause, then run the check above again before you retry the block. If it stopped at an option instead, the attribute ids are already set in your shell: list the options that exist and create only the missing ones with create /attribute_options:

List an attribute's options
curl -s "https://api.revenexx.com/v1/products/attribute_options?attribute_id=$COLOUR_ID" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.items[].code'

A duplicate family, option or family variant code answers 409. A duplicate attribute does not: the 409 only fires when the attribute's entity_ref is set, and entity_ref (which ties an attribute to one reference entity or asset family) is empty for a plain product attribute like these (Data model). So after a partial failure, run the check again rather than the whole block.

The type list is open: text, select, number, metric, price, asset_collection and reference_entity are the ones in use, and an unknown type is shown as a text field. Add "localizable": true for a value per language, or "scopable": true for a value per channel. Those flags decide which of the four buckets of a product's attribute_values holds the value: common (one plain value, as in this guide), locale_specific, channel_specific or channel_locale_specific (Products). Set them before values exist: changing an attribute's type or its localizable and scopable flags later leaves stored values in their old bucket. name is the conventional display-name attribute: it is used when neither the family's label_attribute nor the tenant's default_label_attribute names another.

A family answers "which fields does a drill have". Link each attribute to it, and mark is_required for the ones that must be filled for a product to count as complete:

Create the family
FAMILY_ID=$(create_id /families '{"code":"power_tools","labels":{"en":"Power tools"}}')
: "${FAMILY_ID:?the family was not created, see the error above}"

create /family_attributes "{\"family_id\":\"$FAMILY_ID\",\"attribute_id\":\"$NAME_ID\",\"is_required\":true}"
create /family_attributes "{\"family_id\":\"$FAMILY_ID\",\"attribute_id\":\"$COLOUR_ID\"}"
create /family_attributes "{\"family_id\":\"$FAMILY_ID\",\"attribute_id\":\"$VOLTAGE_ID\"}"

Do not mark the axis attributes (colour, voltage) as required. A required attribute counts against every product of the family, and the model never carries the axis values, so the model would always score as incomplete.

3. Declare the variant structure

A family variant says which attributes a family splits on. Its code is unique across the whole tenant, so name it for the family:

Declare the axes
FAMILY_VARIANT_ID=$(create_id /family_variants "{\"code\":\"power_tools_by_colour_voltage\",\"family_id\":\"$FAMILY_ID\",\"axes\":[\"colour\",\"voltage\"]}")
: "${FAMILY_VARIANT_ID:?the family variant was not created, see the error above}"

A flat list of codes puts both axes on one level. The levelled form, [{"level":1,"axes":["colour"]},{"level":2,"axes":["voltage"]}], is also read, and is how a catalog with a two-step picker is written (Data model). This guide uses the flat form; use the levelled one only when your storefront has the buyer pick one axis before the other. The axes name attributes by code, not by id.

4. Create the model, then the variants

Create the model first. It holds the facts you want kept once, not the axis values. The variants below do not inherit them; a reader that wants the model's description next to a variant reads the model through the variant's parent_id. A fact that differs per variant, such as an EAN, goes on the variant:

Create the model
MODEL_ID=$(create_id "" "{
  \"sku\": \"ACME-4711\", \"kind\": \"model\",
  \"family_id\": \"$FAMILY_ID\", \"family_variant_id\": \"$FAMILY_VARIANT_ID\",
  \"attribute_values\": { \"common\": { \"name\": \"ACME cordless drill 4711\" } }
}")
: "${MODEL_ID:?the model was not created, see the error above}"

Each variant has its own SKU, points at the model with parent_id, and carries the axis values. Send family_id as well as family_variant_id: the tenant's default family (a Products app setting, see the end of step 7) is applied only when neither is sent, so a variant created with a variant structure alone gets no family. This loop creates all four combinations:

Create the four variants
for colour in black red; do
  for voltage in 12v 18v; do
    case $colour in black) c=BLK;; red) c=RED;; esac
    sku="ACME-4711-$c-$(echo $voltage | tr a-z A-Z)"
    row=$(create "" "{
      \"sku\": \"$sku\", \"kind\": \"variant\", \"parent_id\": \"$MODEL_ID\",
      \"family_id\": \"$FAMILY_ID\", \"family_variant_id\": \"$FAMILY_VARIANT_ID\",
      \"attribute_values\": { \"common\": { \"name\": \"ACME cordless drill 4711, $colour, $voltage\", \"colour\": \"$colour\", \"voltage\": \"$voltage\" } }
    }") || break 2
    printf '%s\n' "$row" | jq -r '[.sku, .id] | @tsv'
  done
done

Each line of output is a SKU and its new id; a failed create prints the error and stops the loop. A SKU is unique across the tenant and a repeat answers 409 with code: unique_violation. Nothing checks that parent_id names a model, that the axis values are set, or that two siblings differ, so a variant with no colour is accepted. Completeness does not report it either: it counts only attributes the family marks required, and the axes must not be (step 2). Step 5 is the check that catches it. The products are created enabled, so they are live, unless the new_products_enabled_by_default setting is off for the tenant or the body sends enabled: false (see the end of step 7).

5. Check how the variants show up

GET /v1/products returns every kind as rows and leaves trashed rows out. Add trashed=include to get them too (then skip any row whose deleted_at is set, as the jq filter below does), or trashed=only to read the trash on its own (trash and restore). A storefront finds a model's variants by filtering on the model's id, because there is no separate children route. parent_id and sku are exact-match filters, and order sorts by one column (Pagination & filtering). A page holds at most 200 rows, so limit=200 asks for the most, and a model with more variants than that needs offset for the rest.

The jq filter also prints each variant's axis values, read from attribute_values.common because neither attribute is localizable or scopable, and prints MISSING for one that was never set:

A model's variants
curl -s "https://api.revenexx.com/v1/products?parent_id=$MODEL_ID&order=sku&limit=200" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq -r '.items[] | select(.deleted_at == null)
      | [.sku, .kind, (.attribute_values.common.colour // "MISSING"), (.attribute_values.common.voltage // "MISSING")] | @tsv'
Response
ACME-4711-BLK-12V   variant black   12v
ACME-4711-BLK-18V   variant black   18v
ACME-4711-RED-12V   variant red 12v
ACME-4711-RED-18V   variant red 18v

A MISSING in either column is a variant without an axis value. Set it with the read-merge-write call in step 7, with the right values in its jq filter.

The list the Cockpit shows, GET /v1/products/grid, filters on kind and family_id, one value of each per call. The rows you sell are the simple and the variant ones (a model is not sold, by convention), so ask once with ?kind=variant and, if the catalog also holds standalone products, once with ?kind=simple; in this guide's catalog every sellable row is a variant. The grid does not return parent_id. To read a family's field list, call GET /v1/products/attribute-schema?family_code=power_tools&kind=model; with kind=model the axis fields come back read-only, which is a hint to a form and not a rule of the API (Completeness).

6. Check completeness

Completeness measures a product against the attributes its family marks required, and stores the result on the product. It is measured when you ask, not when you write:

Measure a variant
VARIANT_PRODUCT_ID=$(curl -s "https://api.revenexx.com/v1/products?sku=ACME-4711-BLK-18V" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" | jq -r '.items[0].id // empty')
: "${VARIANT_PRODUCT_ID:?no product with that SKU: create the variants in step 4 first}"

curl -s -X POST "https://api.revenexx.com/v1/products/$VARIANT_PRODUCT_ID/completeness" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" -d '{}'

The answer carries the product's id, sku and family_id and a completeness object with required, filled, ratio, the missing codes and computed_at. A family that requires nothing scores 1. A product reads only its own values, and so does every read of it: a variant does not inherit the model's, so if name is required, every variant needs its own. Run the measure again after a bulk change, because nothing recomputes it for you. A product with no family answers 400 on this call, and its stored completeness is null rather than 0.

7. What a flat import looks like, and how to repair it

If the import ran before the model, the products are bare, and what "bare" means depends on the file. Any file import through /v1/io (Import and export) writes rows and nothing else, so the tenant defaults (default_product_family, new_products_enabled_by_default) apply to none of them.

  • BMECAT (the XML catalog format many suppliers publish; see Import a BMECAT catalog) writes each article as a simple product with enabled true and no parent. The import engine has no family handling at all, so treat every product it writes as having no family. It writes feature values, the EAN and the units under attribute_values.common, and the short and long descriptions under locale_specific.<locale> as name and description. It also discovers attributes from the feed without linking them to any family. The BMECAT feed has no variant mapping.
  • CSV and the other /v1/io formats write the columns you give them, so a row can carry kind, parent_id, family_id and family_variant_id, but only as raw values and ids that already exist. A column you leave out takes the database default: kind is simple and enabled is true, so an imported product is live unless the file says otherwise.

Either way, an imported catalog looks full in the grid, and a product with no family has a null completeness until one is assigned. Check family_id on a few products before you rely on it.

The buckets need no repair. Completeness and the display name look in every bucket, common first and then locale_specific, whatever the attribute's localizable flag says. So a name that BMECAT wrote under locale_specific.<locale> counts as filled for the required name of step 2, and nothing has to be moved. A name you later write under common is read first and wins.

To repair it, build the model as above, then move the products onto it. Start with step 1's check: the import discovers attributes from the feed and types them from the values (text, number or metric), so a code like voltage may already exist as a number and not as the select this guide needs.

  1. Assign the family to each product, which also measures completeness in the same call:
    Assign a family
    curl -s -X POST "https://api.revenexx.com/v1/products/<PRODUCT_ID>/family" \
      -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
      -H "Content-Type: application/json" -d '{ "family_code": "power_tools" }'
    

    The call changes only the family; it deletes no values. A value for an attribute outside the new family stays in attribute_values, but it is hidden: the family's field list (attribute-schema) does not show it and completeness does not count it.
  2. For products that should become variants, create the model first, then update each product to point at it and set its axis values. This update sets family_id itself, so such a product does not need the call in item 1; unlike item 1 it does not measure completeness, so run the measure (step 6) afterwards. Do this after your last BMECAT import: a re-import replaces each existing product's attribute_values with the feed's, which erases axis values you set (kind, parent_id, family_id and enabled stay).
    PUT changes only the fields the body names, so sku, enabled and the rest stay as they are. attribute_values counts as one field and is replaced as a whole: any value you leave out of the body is deleted, and no call brings it back. Never send a body with only the axis values. Read the product, merge your axis values into what it holds, and send the merged body.

    This reads, merges and writes in one go. Set PRODUCT_ID below, make sure MODEL_ID, FAMILY_ID and FAMILY_VARIANT_ID hold the real ids, and put the product's own colour and voltage in the jq filter:
    Read, merge, write
    PRODUCT_ID="<PRODUCT_ID>"
    : "${MODEL_ID:?set MODEL_ID to the id of the model}" "${FAMILY_ID:?set FAMILY_ID to the id of the family}" \
      "${FAMILY_VARIANT_ID:?set FAMILY_VARIANT_ID to the id of the family variant}" &&
    BODY=$(curl -sS "https://api.revenexx.com/v1/products/$PRODUCT_ID" \
      -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
      | jq -c --arg model "$MODEL_ID" --arg family "$FAMILY_ID" --arg fv "$FAMILY_VARIANT_ID" '
          select(.id != null) | {
            kind: "variant", parent_id: $model, family_id: $family, family_variant_id: $fv,
            attribute_values: ((.attribute_values // {}) * { common: { colour: "black", voltage: "18v" } })
          }') && [ -n "$BODY" ] &&
    curl -sS -X PUT "https://api.revenexx.com/v1/products/$PRODUCT_ID" \
      -H "X-Revenexx-Tenant: $REVENEXX_TENANT" -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
      -H "Content-Type: application/json" -d "$BODY"
    

    The * merges the two objects key by key, so locale_specific and the other buckets, and every other common value, are sent back unchanged. // {} covers a product with no attribute_values yet. If the GET fails, BODY stays empty and nothing is written.

The Products app settings default_product_family and new_products_enabled_by_default apply to products you create through the API, not to a file import. default_label_attribute decides which attribute a product's display name is read from (Products). An operator sets all three in Cockpit's settings form for the Products app, per market, and GET /v1/settings/apps/products reads the values in force (Settings). The API cannot change them.

Troubleshooting

SymptomCauseFix
A variant has no familyIt was created with family_variant_id and no family_id, so the default family was skippedSend both (step 4), or assign the family afterwards
Create answers 409The SKU, or the family variant code, is already taken in the tenantChoose another code; a SKU is the key every integration joins on, so decide it once
Completeness answers 400The product has no familyAssign one with POST /v1/products/<ID>/family (step 7)
The model always scores incompleteAn axis attribute is marked requiredUnmark it on the family link (step 2)
A variant shows MISSING for colour or voltage (step 5)The axis value was never set; nothing checks it and completeness does not count itSet it with the read-merge-write PUT in step 7, never a PUT of the axis values alone, which replaces the whole attribute_values
A grid shows models and variants togetherThey are all productsFilter the grid on kind (step 5)

Where to go next

Model one product family end to end by hand, then load the rest. Then read:

Was this page helpful?