docs
/
AppEngine API

Product attributes

The two-layer model behind configurable products — the reusable definition, the per-product subset, every attribute type, file uploads, the required rule and how option prices reach the order.

Configurable products are built from two collections. Reference for both, and for the rules that govern how their values are priced and persisted.

CollectionRole
sf_attributeThe reusable definition — the full option list
sf_product.attributes[]The per-product reference — this product's subset and its own required decision

The split exists so one color attribute can serve every product while each product offers only the colours it sells.

sf_attribute

{
  "name": "color",
  "title": "Colour",
  "type": "selection",
  "display": "color",
  "multiple": false,
  "accept": null,
  "maxFiles": null,
  "minValue": null,
  "maxValue": null,
  "minIncrement": null,
  "options": [
    { "label": "Navy", "value": "navy", "color": "#1e3a8a", "priceDelta": 0 }
  ]
}
FieldApplies toMeaning
typeallselection · number · date · text · file
displayselectionHow the control renders — see below
multiplefileAllow more than one file
acceptfileComma list of extensions; blank means any
maxFilesfileCap when multiple; blank means unlimited
minValue, maxValue, minIncrementnumberInput bounds and step
options[]selectionThe full option list

Schema sources: sdk/src/models/storefront/sf-attribute.ts, sf-product.ts, file-info.ts.

sf_product.attributes[]

{
  "name": "color",
  "required": false,
  "options": [
    { "label": "Navy", "value": "navy", "price": 0 },
    { "label": "Oak",  "value": "oak",  "price": 12 }
  ]
}

Two differences from the definition, both easy to miss:

The price field is renamed. priceDelta on the definition; price on the product's copy. Both are deltas added to the base price, never absolutes.

It is a denormalised copy. Editing the shared definition does not update products already referencing it. That prevents a catalogue edit from silently changing live product pricing, at the cost of making a catalogue-wide option change a migration rather than an edit.

Storage

Attributes live at data.attributes. Write with the dot-path:

req("POST", f"/repository/update-partial/sf_product/{sk}", {
    "sk": sk, "version": rec["version"],
    "data.attributes": attributes,
}, token=t)

{"data": {...}} replaces the entire data object — price, images, parcel and everything else you did not include. Snapshot with find before any bulk write.

Option presentation

An option carries the stored answer and its presentation separately:

FieldPurpose
valueThe stored answer — "navy"
colorHex swatch, used when display: 'color'
imageFileInfo array, used when display: 'image'

Do not put a hex code or URL in value. Renderers read option.color and option.image[0].url, falling back to value only for legacy data. A product whose value is "#1e3a8a" stores that string as the customer's answer, and it appears on the order and the packing slip.

Types and controls

type · displayControlStored as
selection · colorColour swatchesOption index
selection · imageImage tilesOption index
selection · radio (or blank)Pill buttonsOption index
selection · selectDropdownOption index
selection · checkboxMulti-selectMap of checked indexes
selection · range-slider / range-inputSlider across optionsOption index
textInput; textarea when the name matches /instruction/iString
numberNumber input honouring minValue / maxValue / minIncrementNumber
dateDate inputString
fileUpload zoneFileInfo[]

Storefronts iterate product.attributes and render by type and display. Nothing is hard-coded per product: add an attribute to the record and its control appears.

File attributes

Each upload becomes a FileInfo:

{
  "path": "org/artwork/logo.ai",
  "url": "https://…",
  "contentType": "application/postscript",
  "isPublic": true,
  "meta": { "width": 0, "height": 0, "size": 284113 }
}

Per-file notes go at meta.note. There is no notes field in the schema and none is needed — meta is an open object. This matters for print work, where someone uploading four files has something different to say about each.

Placement: sf_product.preview.views[].zones[] maps a file attribute by name to a box on a mockup — x, y, width, height as percentages, plus rotation and fit — which is what makes front/back compositing possible.

Binaries upload separately:

curl -X POST "$APPMINT_HOST/repository/file/upload" \
  -H "orgid: $APPMINT_ORG" -H "Authorization: Bearer $TOKEN" \
  -F "location=artwork" -F "[email protected]"

location is the folder — the endpoint appends the filename.

The required rule

required lives on the per-product entry, not the shared definition.

null, undefined and true all mean REQUIRED. Only an explicit false means optional.

The default is required; opting out must be explicit.

TypeBehaviour
file, text, number, dateBlocks submit until satisfied
selectionAlways has a value (index 0), so auto-satisfied — never blocks, and should not show a required tag

Enforcement is client-side, in the storefront's submit guard. The server does not check it. Anything you genuinely cannot fulfil without must also be validated where the order is processed.

Pricing

Deltas reach the server only through options[], with a numeric price:

options: [
  { name: 'color', label: 'Colour', value: 'Oak',   price: 12 },
  { name: 'size',  label: 'Size',   value: 'Large', price: 90 }
]

storefront/pricing/calculate-cart counts nothing that arrives any other way. Choices recorded in a freeform attributes object are displayed but not charged.

ControlContribution
Radio / select / colour / imageThe chosen option's price
CheckboxThe sum of every checked option
ToggleOption 1's price when on
QuantityMultiplies the line — not a delta

calculatedPrice on a product is an object — { finalPrice, originalPrice, discount, … }. Read .finalPrice.

recalc() folds the server's figures back after every cart mutation. Render those, never a client-side sum.

Order persistence

storefront/checkout-cart builds the order from productItems and persists options[] = [{ name, label, value, price }]. It drops freeform attributes and design.

Push every choice through options[], including those priced at zero. A fact recorded only in attributes does not reach the order, and the gap surfaces when someone in fulfilment asks which colour was ordered.