Migrating to the 2026-10 Products API
Migrating to the 2026-10 Products API
This guide helps you migrate from the legacy product endpoints to the date-based 2026-10 Products API.
The legacy APIs were spread across several versions (2.0, 2.1, 3.0, 2026-01, 2026-04 and 2026-07) and modelled a product family as an implicit grouping of products that happened to share a name. This was exposed via the variant_parent_id as discussed in previous variant documentation. The 2026-10 API replaces this with an explicit product family resource: a product_family owns its member products, and every product points back at its family through family_id.
This is the most important change to internalise before migrating: the family is now a first-class object you create, read, and update directly, and product data is split between the family (shared) and its members (individual). See the product family guide for details.
Who should read this
Any user that currently relies on the following product related endpoints must be migrated to the new endpoints within 12 months of the release of 2026-10.
- The read endpoints
GET /api/2.0/products,GET /api/2.0/products/{id},GET /api/2.0/products/crayon/{id},POST /api/2.0/products/bulk, orPOST /api/2.0/variants/bulk. - The write endpoints
POST /api/2.0/products,POST /api/2.1/products,PUT /api/2.1/products/{id}, orDELETE /api/2.0/products/{id}.
Note: This guide uses the
2026-10version in examples. Legacy examples keep their original/api/2.0/and/api/2.1/paths. ReplaceDOMAIN_PREFIXwith your store's prefix in all URLs.
The conceptual model change
| Legacy concept | 2026-10 concept | Notes |
|---|---|---|
Implicit family (products sharing a name) | Explicit product_family with its own id | You now create and address the family directly. |
variant_parent_id / has_variants (read-only flags used to infer structure) | classification + family_id | A family declares its classification (STANDARD, VARIANT, or COMPOSITE). Membership is explicit via family_id. |
| The product carried everything (family-level and individual data mixed together) | Split between product_family and product | See the table below. |
2.1 common / details split | product_family (family) and product (product) resources | The conceptual split stays, but it is now expressed as two separate resources and endpoints. |
What lives where
The 2026-10 API splits family-level data and product-level data.
Product family (shared by every member): name, description, classification, brand_id, category_id, track_inventory, variant_attribute_ids, tag_ids, composite_components, customizations, and family-level images.
Product (specific to one product): variant_name, active, source, account_code, variant_attributes (the values), prices, measurements, codes, suppliers, packaging, outlet_taxes, outlet_inventories, and product-level images.
Endpoint changes
Read endpoints
| Action | Legacy | 2026-10 |
|---|---|---|
| List products | GET /api/2.0/products | [GET /api/2026-10/products] |
| Find a product by SKU | GET /api/2.0/products?sku=... | GET /api/2026-10/products?sku=... |
| Get a single product | GET /api/2.0/products/{id} | [GET /api/2026-10/products/{product_id}] |
| Get a whole variant family | GET /api/2.0/products/crayon/{id} | [GET /api/2026-10/product_families/{product_family_id}] |
| Get the family for a product | (infer from name / variant_parent_id) | [GET /api/2026-10/products/{product_id}/family] |
| Bulk fetch products | POST /api/2.0/products/bulk | [GET /api/2026-10/products/{product_id}] (per product) |
| Bulk fetch variants | POST /api/2.0/variants/bulk | [GET /api/2026-10/product_families/{product_family_id}] |
Requires:products:readscope
Note: The legacy
crayon/{id}endpoint returned the parent product with a nestedvariantsarray. The2026-10equivalent isGET /product_families/{id}, which returns the family object with aproductsarray. To go from a product you already hold to its family, useGET /products/{product_id}/family.
Write endpoints
| Action | Legacy | 2026-10 |
|---|---|---|
| Create a product or variant family | POST /api/2.0/products | [POST /api/2026-10/product_families] |
| Add a variant to a family | POST /api/2.1/products | [POST /api/2026-10/product_families/{product_family_id}/products] |
| Update family-level fields | PUT /api/2.1/products/{id} (common) | PATCH /api/2026-10/product_families/{product_family_id} |
| Update a single product | PUT /api/2.1/products/{id} (details) | PATCH /api/2026-10/products/{product_id} |
| Delete a single product | DELETE /api/2.0/products/{id} | DELETE /api/2026-10/products/{product_id} |
| Delete a whole family | DELETE /api/2.0/products/{id}/all | DELETE /api/2026-10/product_families/{product_family_id} |
| Add an image | POST /api/2.0/products/{id}/actions/image_upload | POST /api/2026-10/products/{entity_id}/images or POST /api/2026-10/product_families/{entity_id}/images |
| Remove an image | DELETE /api/2.0/product_images/{product_image_id} | DELETE /api/2026-10/products/{entity_id}/images/{image_id} or DELETE /api/2026-10/product_families/{entity_id}/images/{image_id} |
Requires:products:writescope
Note: The legacy single
PUTis split into twoPATCHendpoints. Update the family (PATCH /product_families/{id}) for shared fields such asname,brand_id,variant_attribute_ids,tag_ids,composite_bom, andcustomizations. Update a single product (PATCH /products/{id}) for individual fields such asprices,codes,suppliers, andoutlet_inventories.
Reading products: payload comparison
Legacy GET /api/2.0/products/{id}
GET /api/2.0/products/{id}The legacy product was a large, flat object. Notable quirks: is_active and active are duplicated, prices are top-level numbers, inventory tracking is the boolean has_inventory, and there is no field literally named price.
{
"data": {
"id": "0a6f6e94-7d1f-11e4-eed5-aaaa00112233",
"variant_parent_id": null,
"name": "T-Shirt",
"variant_name": "T-Shirt / Red / L",
"handle": "t-shirt",
"sku": "TSHIRT-RED-L",
"is_active": true,
"active": true,
"has_inventory": true,
"has_variants": true,
"variant_count": 6,
"is_composite": false,
"description": "A comfy cotton t-shirt",
"price_including_tax": 22.99,
"price_excluding_tax": 19.99,
"loyalty_amount": 0,
"version": 254310,
"brand_id": "b1a2...",
"product_type_id": "c3d4...",
"account_code": "4000",
"account_code_purchase": "5000",
"variant_options": [
{ "id": "attr-color", "name": "Color", "value": "Red" },
{ "id": "attr-size", "name": "Size", "value": "L" }
],
"tag_ids": ["tag-1", "tag-2"],
"outlet_taxes": [
{ "outlet_id": "out-1", "tax_id": "tax-1", "is_default": true }
],
"composite_bom": {},
"product_codes": [{ "id": "pc-1", "type": "EAN", "code": "0123456789012" }],
"weight": null,
"weight_unit": null,
"family_id": "fam-1",
"tax_category": null
},
"includes": null
}New GET /api/2026-10/products/{product_id}
GET /api/2026-10/products/{product_id}The member product is leaner. Shared fields (name, description, brand, tags, classification) have moved to the family. Money is now a quoted decimal string inside a prices object, and active is an object.
{
"data": {
"id": "0a6f6e94-7d1f-11e4-eed5-aaaa00112233",
"family_id": "fam-1",
"variant_name": "T-Shirt / Red / L",
"sku": "TSHIRT-RED-L",
"active": { "in_store": true, "ecwid": false },
"source": { "source": "USER", "id": null, "variant_id": null },
"account_code": { "sale": "4000", "purchase": "5000" },
"variant_attributes": ["Red", "L"],
"prices": {
"price_excluding_tax": "19.99",
"price_including_tax": "22.99",
"tax": "3.00",
"loyalty_amount": null
},
"measurements": {
"weight": null,
"weight_unit": null,
"dimensions_length": null,
"dimensions_width": null,
"dimensions_height": null,
"dimensions_unit": null
},
"sequence": 0,
"version": 254310,
"images": [],
"suppliers": [],
"packaging": [],
"codes": [
{
"type": "EAN",
"code": "0123456789012",
"created_at": "...",
"updated_at": "..."
}
],
"outlet_taxes": [
{
"outlet_id": "out-1",
"outlet_default_tax_id": "tax-1",
"tax_id": null,
"created_at": "...",
"updated_at": "...",
"deleted_at": null
}
],
"created_at": "2024-01-15T09:30:00Z",
"updated_at": "2024-03-02T14:05:12Z",
"deleted_at": null
}
}New GET /api/2026-10/product_families/{product_family_id}
GET /api/2026-10/product_families/{product_family_id}To retrieve the whole family (the replacement for crayon/{id} and variants/bulk), call the family endpoint. The family object carries the shared fields and nests its members under products.
{
"data": {
"id": "fam-1",
"name": "T-Shirt",
"description": "A comfy cotton t-shirt",
"classification": "VARIANT",
"family_count": 6,
"brand_id": "b1a2...",
"category_id": "c3d4...",
"track_inventory": true,
"variant_attribute_ids": ["attr-color", "attr-size"],
"tag_ids": ["tag-1", "tag-2"],
"images": [],
"composite_components": null,
"customization_ids": [],
"created_at": "2024-01-15T09:30:00Z",
"updated_at": "2024-03-02T14:05:12Z",
"deleted_at": null,
"products": [
{
"id": "0a6f6e94-...",
"family_id": "fam-1",
"variant_name": "T-Shirt / Red / L",
"variant_attributes": ["Red", "L"],
"...": "..."
}
]
},
"includes": null
}Read field mapping
| Legacy field | New field | Notes |
|---|---|---|
name (on product) | name (on family) | The family owns the name. Members no longer carry it, instead, they have variant_name. |
description | description (on family) | Moved to the family. |
variant_parent_id, has_variants, variant_count, is_composite | classification + family_count | Structure is now declared by the family's classification and family_count, not inferred from flags. |
variant_options[] {id, name, value} | family variant_attribute_ids[] + product variant_attributes[] | The family lists the attribute IDs (and their order). Each product carries only the ordered values. |
price_including_tax, price_excluding_tax, loyalty_amount (top-level numbers) | prices {price_excluding_tax, price_including_tax, tax, loyalty_amount} | Now grouped, and serialized as quoted decimal strings to preserve precision. |
has_inventory (boolean) | family track_inventory | Inventory tracking is a family setting. |
is_active / active (duplicated boolean) | active {in_store, ecwid} | Replaced by an object with explicit channels. The duplicate field is gone. |
account_code, account_code_purchase | account_code {sale, purchase} | Grouped into one object (account_code was the sales code). |
brand_id (+ brand) | family brand_id | Moved to the family. Hydrate the full brand with includes[]=brands. |
product_type_id (+ type) | family category_id | The legacy "product type" is the family category. Hydrate the full category with includes[]=categories. |
tag_ids | family tag_ids[] | Moved to the family. Hydrate full tags with includes[]=tags. |
outlet_taxes[] {outlet_id, tax_id, is_default} | outlet_taxes[] {outlet_id, outlet_default_tax_id, tax_id} | is_default is replaced by outlet_default_tax_id; a per-outlet override goes in tax_id. |
composite_bom {product_id: qty} (object/map) | family composite_components[] {product_id, quantity} (array) | Now an array of objects on the family. |
weight, weight_unit, length, width, height, dimensions_unit (top-level) | measurements {...} | Grouped into one object. Units are enums (e.g. KG, CM). |
product_codes[] {id, type, code} | codes[] {type, code, ...} | Renamed product_codes → codes. |
customizations[] | family customizations / customization_ids | Customizations belong to the family. |
image_url, image_thumbnail_url, images[], skuImages[] | images[] {id, url, sizes, ...} | Consolidated. Now have images on the product family entity, and the product. |
handle | — | No longer available. |
?includes[]=composite_products (under includes) | includes[] query param | Supported values: brands, categories, suppliers, tags, variant_attributes, families, composite_components. |
VersionedCollection / DataWrapper envelope | data + version {min, max} (list) / data + includes | The list response still carries versioning info. Single responses are wrapped in data. |
Includes section
By default, a product or family response contains only the IDs of related entities — brand_id, category_id, tag_ids, variant_attribute_ids, supplier IDs on suppliers[], and so on. It does not embed the full brand, category, tag, supplier, or attribute records.
This is deliberate. Most integrations already hold this reference data, or don't need it on every read, so fully hydrating every response would waste bandwidth and add load. When you do need the full records, ask for them explicitly with the includes[] query parameter. This replaces the legacy side-loading behaviour, which only supported includes[]=composite_products on the crayon/{id} endpoint.
Pass a single, comma-separated includes[] parameter on any GET endpoint:
GET /api/2026-10/products/{product_id}?includes[]=brands,tags,suppliers
Requested entities are hydrated under a top-level includes object, keyed by type. Each key is only present when it was requested and there is data to return — so a key may be absent even if you asked for it (for example, brands is omitted when no returned product has a brand_id). When you request nothing, includes is null.
The available parameters are:
| Parameter | Hydrates | Resolved from |
|---|---|---|
brands | Full brand records | The family brand_id |
categories | Full category records | The family category_id |
tags | Full tag records | The family tag_ids |
suppliers | Full supplier records | The supplier_id of each product's suppliers[] |
variant_attributes | Full variant attribute records (e.g. "Color", "Size") | The family variant_attribute_ids |
customizations | Full customization-field records | The family customization_ids |
families | The full product_family object for each returned product | Each product's family_id |
composite_components | The full member products that make up a composite | The composite_bom[].product_id of composite families |
Note: Not every parameter applies to every endpoint.
familiesis most useful on the product endpoints (GET /productsandGET /products/{product_id}), where it loads the family each product belongs to.composite_componentsapplies to the family endpoints and only returns data forCOMPOSITEfamilies. Unknown values inincludes[]are ignored.
Example request:
GET /api/2026-10/products/{product_id}?includes[]=brands,suppliers
Example response (abbreviated):
{
"data": {
"id": "0a6f6e94-...",
"family_id": "fam-1",
"variant_name": "T-Shirt / Red / L",
"suppliers": [
{
"id": "ps-1",
"supplier_id": "sup-1",
"code": "TS-RED-L",
"price": "7.50"
}
],
"...": "..."
},
"includes": {
"brands": [
{ "id": "brand-1", "name": "Acme Apparel", "description": null }
],
"suppliers": [{ "id": "sup-1", "name": "Acme Supply Co", "source": "USER" }]
}
}Writing products: payload comparison
Legacy POST /api/2.0/products
POST /api/2.0/productsThe legacy create was a single flat payload: top-level family fields, plus a variants array where each variant declared its options through variant_definitions.
{
"name": "Variant Colors",
"variants": [
{
"sku": "RED-S",
"price_excluding_tax": 19.99,
"variant_definitions": [
{ "attribute_id": "069db350-...", "value": "Red" },
{ "attribute_id": "2f0ff40b-...", "value": "S" }
]
},
{
"sku": "BLUE-M",
"price_excluding_tax": 19.99,
"variant_definitions": [
{ "attribute_id": "069db350-...", "value": "Blue" },
{ "attribute_id": "2f0ff40b-...", "value": "M" }
]
}
]
}Response: an array of the created product IDs.
{ "data": ["6f32baa8-...", "cae9eebf-..."] }Parent and variant fields in the legacy payload
The legacy payload is confusing to migrate because the same field set can appear in two places: at the top level and inside each variants[] entry. Understanding which fields are shared and which are per-product is the key to mapping it onto the new model.
The first entry in variants[] is the variant parent (and defines the family). Its variant_definitions establish the family's variant attributes. Every other variant must repeat the same attribute IDs (same set, same count) or the request is rejected.
Fields fall into three buckets:
| Bucket | Fields | Behaviour |
|---|---|---|
| Family-level (top level only) | name, description, handle, account_code_sale, account_code_purchase, brand_id, product_type_id / product_category_id, tag_ids, source, composite | Describe the whole family. Set once at the top level. Applied to every product. |
Per-product (inside each variants[] entry) | variant_definitions, sku, product_codes, price_including_tax / price_excluding_tax, loyalty_amount, all_outlets_tax / outlet_taxes, inventory, images, weight / length / width / height and their units, is_active | Differ per variant. Each member must have a unique sku and a unique combination of variant_definitions. |
| Accepted in both, but per-member wins | the per-member fields above also exist at the top level | When a variants[] array is present, the top-level copies of these fields are ignored — each variant supplies its own. The top-level versions only take effect for a standard or composite product. |
Note: Supplier data is a special case. When creating variants, suppliers must be listed at the variant level, not the top level (providing top-level
product_suppliersalongsidevariantsis rejected with "Only list supplier data on variant level."). On top of that, every variant must share the same set of suppliers as the parent — so although suppliers are written per-variant, they behave as a family-wide concept.
The 2026-10 API removes some of the ambiguity by making the split explicit: family-level fields move onto the product_family, the variant attribute IDs are declared once in variant_attribute_ids, and each member under products[] carries only its own values. See What lives where and the write field mapping for the full mapping.
Note: The legacy "every variant must share the same suppliers" rule does not apply in the new model. Each member's
suppliers[]is independent, so different variants in the same family can have entirely different suppliers (a supplier may still only appear once per product).
New POST /api/2026-10/product_families
POST /api/2026-10/product_familiesThe new create declares the family explicitly (classification, variant_attribute_ids) and lists its members under products. Each product supplies its ordered attribute values in variant_attributes, and its own codes, prices, and tax.
{
"name": "Variant Colors",
"classification": "VARIANT",
"track_inventory": false,
"variant_attribute_ids": ["069db350-...", "2f0ff40b-..."],
"products": [
{
"variant_attributes": ["Red", "S"],
"prices": { "price_excluding_tax": "19.99" },
"codes": [{ "type": "CUSTOM", "code": "RED-S" }]
},
{
"variant_attributes": ["Blue", "M"],
"prices": { "price_excluding_tax": "19.99" },
"codes": [{ "type": "CUSTOM", "code": "BLUE-M" }]
}
]
}Response: the new family ID and the IDs of the created members.
{
"data": {
"product_family_id": "fam-1",
"product_ids": ["6f32baa8-...", "cae9eebf-..."]
}
}Write field mapping
| Legacy field | New field | Notes |
|---|---|---|
name, description, brand_id, product_type_id, product_category_id | name, description, brand_id, category_id (on the family) | product_category_id is renamed category_id product_type_id has been removed. |
(implied by presence of variants) | classification | Now an explicit, required enum: STANDARD, VARIANT, or COMPOSITE. |
tag_ids | tag_ids (on the family) | Unchanged in name, but set on the family. |
track_inventory (2.1 common) / has_inventory | track_inventory (on the family) | A family-level flag. |
variants[] | products[] | Members are now ProductRequest objects under the family. |
variants[].variant_definitions[] {attribute_id, value} | family variant_attribute_ids[] + product variant_attributes[] | Declare the attribute IDs (and order) once on the family; give each member its ordered values. |
variant_attribute_values[] {attribute_id, attribute_value} (2.1) | product variant_attributes[] (ordered values) | Values follow the order of the family's variant_attribute_ids. |
sku (optional; auto-generated when omitted) | a CUSTOM entry in codes[] (required) | Provide the SKU as a product code. Codes are now required on each product. See Generating product codes. |
product_codes[] | codes[] | Renamed. |
price_including_tax, price_excluding_tax, loyalty_amount | prices {...} | Grouped. |
all_outlets_tax, outlet_taxes[] | tax_id + outlet_taxes[] {outlet_id, tax_id} | A single tax goes in tax_id; per-outlet overrides go in outlet_taxes[]. |
inventory[] | outlet_inventories[] | Renamed; same per-outlet current_amount / reorder_point / reorder_amount fields. |
product_suppliers[] | suppliers[] | Renamed. Each variant can now have a different set of suppliers. |
packaging[] (by ID) | packaging[] (by SKU) | Packaging now references members by outer_pack_product_sku / inner_pack_product_sku. |
is_active | active {in_store, ecwid} | Object with explicit channels. |
composite | composite_components[] {product_id, quantity} (on the family) | For COMPOSITE families. |
Generating product codes
This is one of the most important behavioural changes to plan for.
In the legacy POST /api/2.0/products flow, product codes were generated implicitly: if a member was created without a sku (or product code), the system automatically allocated one from your store's SKU sequence.
In the 2026-10 API, codes are explicit. Every product in a product_families create request, and when adding additional products, requires at least one entry in codes[]. An empty codes array is rejected — there is no automatic allocation during create.
To reproduce the old "generate a code for me" behaviour, request codes up front from the dedicated endpoint and place them in your create payload.
POST /api/2026-10/products/generate_product_codes
POST /api/2026-10/products/generate_product_codes
Requires:products:writescope
See generating product codes for more information.
Updating and deleting
Splitting the update
The legacy PUT /api/2.1/products/{id} accepted a common block (family fields) and a details block (member fields) in one request. In 2026-10 these become two PATCH requests:
PATCH /api/2026-10/product_families/{product_family_id}— the oldcommonblock. Use it forname,description,brand_id,category_id,track_inventory,tag_ids,variant_attribute_ids,composite_bom, andcustomizations.PATCH /api/2026-10/products/{product_id}— the olddetailsblock. Use it foractive,account_code,prices,measurements,codes,suppliers,packaging,outlet_taxes, andoutlet_inventories.
Updating the family name (previously sent in common.name, applied to every member):
{
"name": "Puma T-Shirt",
"description": "The latest batch of T-Shirts from Puma."
}PATCH /api/2026-10/product_families/{product_family_id}
Updating a single member's price (previously sent in details):
{ "prices": { "price_excluding_tax": "24.99" } }PATCH /api/2026-10/products/{product_id}
Both PATCH endpoints return the affected resource's ID:
{ "data": { "id": "fam-1" } }PATCH semantics (three states per field)
PATCH semantics (three states per field)The PATCH endpoints distinguish three states for each field, which is important when clearing values:
- Key omitted — the field is left untouched.
- Key present with
null— the field is cleared/unset (for booleans,nullis treated asfalse). - Key present with a value — the field is set to that value (for strings,
""is treated the same asnull). For collections, such as codes, the entire collection is replaced with the new array. Any existing entries not present in the new array are deleted.
Deleting
The legacy DELETE /api/2.0/products/{id} deleted a single product, and .../{id}/all deleted the whole family. These map to two distinct resources:
DELETE /api/2026-10/products/{product_id}— delete a single member.DELETE /api/2026-10/product_families/{product_family_id}— delete the whole family.
Both return 204 No Content. Deletion cannot be undone.
Key behavioural differences
- Monetary values are quoted decimal strings.
prices.price_excluding_taxis"19.99", not19.99. This preserves precision. Quantities and measurements (for examplecomposite_components[].quantity) remain unquoted numbers. - No more
variant_parent_id/has_variants. Determine structure from the family'sclassification. To find a product's family, callGET /products/{product_id}/family. - Family vs member is enforced. Sending a family field (such as
name) on a memberPATCH, or vice versa, is not valid. Use the correct endpoint. - Unknown fields are rejected. Write requests reject unknown JSON keys with a
400. Remove any legacy-only fields from your payloads. - Validation errors return
422. Semantic validation failures (for example an invalid tax reference) return422 Unprocessable Entity, often with anerror_fieldsobject identifying the offending fields. - Product codes are explicit on create. Each member requires at least one entry in
codes[]. An empty array is rejected. The legacy automatic SKU generation is gone. UsePOST /api/2026-10/products/generate_product_codesto generate codes from your SKU sequence first. See Generating product codes. - Images use dedicated endpoints. The legacy image-token-then-S3-upload flow is replaced by
POST /products/{id}/imagesandPOST /product_families/{id}/images, which accept either a JSON body ({ "type": "...", "value": "..." }) or amultipart/form-dataupload in theimagefield.
Next steps
For background on how variant families behave (parent/child relationships, attribute inheritance, and ordering), see Variants. The existing variant guides — Creating variants and Updating variants — describe the legacy 2.0/2.1 flows and are useful context, though the 2026-10 resource model described above supersedes them. For composite products, see Creating composite products and Updating composite products. For image uploads, see Image upload basics.
