Model a catalog with attributes, families and variants
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 listprints 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.completenessandproducts.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:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
Decide the model first
Three questions settle almost everything. Answer them on paper, with the customer, before you create a row:
| Question | If the answer is… | Then it is a… |
|---|---|---|
| What facts do we keep about a product (name, colour, weight, EAN)? | each fact | Attribute, with a code and a type |
| Which products carry the same set of facts? | all drills, all cables | Family, linked to its attributes |
| Within one family, what makes two products "the same product in another size"? | colour and voltage | Variant 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:
simplestands alone.modelgroups 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).variantis 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):
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:
# 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:
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.
2. Create the family and link its attributes
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:
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:
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:
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:
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:
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'
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:
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
simpleproduct withenabledtrue 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 underattribute_values.common, and the short and long descriptions underlocale_specific.<locale>asnameanddescription. 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/ioformats write the columns you give them, so a row can carrykind,parent_id,family_idandfamily_variant_id, but only as raw values and ids that already exist. A column you leave out takes the database default:kindissimpleandenabledis 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.
- 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 inattribute_values, but it is hidden: the family's field list (attribute-schema) does not show it and completeness does not count it. - 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_iditself, 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'sattribute_valueswith the feed's, which erases axis values you set (kind,parent_id,family_idandenabledstay).PUTchanges only the fields the body names, sosku,enabledand the rest stay as they are.attribute_valuescounts 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. SetPRODUCT_IDbelow, make sureMODEL_ID,FAMILY_IDandFAMILY_VARIANT_IDhold the real ids, and put the product's own colour and voltage in thejqfilter:Read, merge, writePRODUCT_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, solocale_specificand the other buckets, and every othercommonvalue, are sent back unchanged.// {}covers a product with noattribute_valuesyet. If the GET fails,BODYstays 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
| Symptom | Cause | Fix |
|---|---|---|
| A variant has no family | It was created with family_variant_id and no family_id, so the default family was skipped | Send both (step 4), or assign the family afterwards |
Create answers 409 | The SKU, or the family variant code, is already taken in the tenant | Choose another code; a SKU is the key every integration joins on, so decide it once |
Completeness answers 400 | The product has no family | Assign one with POST /v1/products/<ID>/family (step 7) |
| The model always scores incomplete | An axis attribute is marked required | Unmark 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 it | Set 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 together | They are all products | Filter 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:
- Data model, Products and Completeness: the reference behind each step.
- Import a BMECAT catalog and Import and export: loading the data into the model.
- Choose which system owns each entity: who maintains the model once it exists.