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, or POST /api/2.0/variants/bulk.
  • The write endpoints POST /api/2.0/products, POST /api/2.1/products, PUT /api/2.1/products/{id}, or DELETE /api/2.0/products/{id}.

Note: This guide uses the 2026-10 version in examples. Legacy examples keep their original /api/2.0/ and /api/2.1/ paths. Replace DOMAIN_PREFIX with your store's prefix in all URLs.

The conceptual model change

Legacy concept2026-10 conceptNotes
Implicit family (products sharing a name)Explicit product_family with its own idYou now create and address the family directly.
variant_parent_id / has_variants (read-only flags used to infer structure)classification + family_idA 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 productSee the table below.
2.1 common / details splitproduct_family (family) and product (product) resourcesThe 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

ActionLegacy2026-10
List productsGET /api/2.0/products[GET /api/2026-10/products]
Find a product by SKUGET /api/2.0/products?sku=...GET /api/2026-10/products?sku=...
Get a single productGET /api/2.0/products/{id}[GET /api/2026-10/products/{product_id}]
Get a whole variant familyGET /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 productsPOST /api/2.0/products/bulk[GET /api/2026-10/products/{product_id}] (per product)
Bulk fetch variantsPOST /api/2.0/variants/bulk[GET /api/2026-10/product_families/{product_family_id}]
🔒

Requires: products:read scope

Note: The legacy crayon/{id} endpoint returned the parent product with a nested variants array. The 2026-10 equivalent is GET /product_families/{id}, which returns the family object with a products array. To go from a product you already hold to its family, use GET /products/{product_id}/family.

Write endpoints

ActionLegacy2026-10
Create a product or variant familyPOST /api/2.0/products[POST /api/2026-10/product_families]
Add a variant to a familyPOST /api/2.1/products[POST /api/2026-10/product_families/{product_family_id}/products]
Update family-level fieldsPUT /api/2.1/products/{id} (common)PATCH /api/2026-10/product_families/{product_family_id}
Update a single productPUT /api/2.1/products/{id} (details)PATCH /api/2026-10/products/{product_id}
Delete a single productDELETE /api/2.0/products/{id}DELETE /api/2026-10/products/{product_id}
Delete a whole familyDELETE /api/2.0/products/{id}/allDELETE /api/2026-10/product_families/{product_family_id}
Add an imagePOST /api/2.0/products/{id}/actions/image_uploadPOST /api/2026-10/products/{entity_id}/images or POST /api/2026-10/product_families/{entity_id}/images
Remove an imageDELETE /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:write scope

Note: The legacy single PUT is split into two PATCH endpoints. Update the family (PATCH /product_families/{id}) for shared fields such as name, brand_id, variant_attribute_ids, tag_ids, composite_bom, and customizations. Update a single product (PATCH /products/{id}) for individual fields such as prices, codes, suppliers, and outlet_inventories.

Reading products: payload comparison

Legacy 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}

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}

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 fieldNew fieldNotes
name (on product)name (on family)The family owns the name. Members no longer carry it, instead, they have variant_name.
descriptiondescription (on family)Moved to the family.
variant_parent_id, has_variants, variant_count, is_compositeclassification + family_countStructure 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_inventoryInventory 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_purchaseaccount_code {sale, purchase}Grouped into one object (account_code was the sales code).
brand_id (+ brand)family brand_idMoved to the family. Hydrate the full brand with includes[]=brands.
product_type_id (+ type)family category_idThe legacy "product type" is the family category. Hydrate the full category with includes[]=categories.
tag_idsfamily 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_codescodes.
customizations[]family customizations / customization_idsCustomizations 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.
handleNo longer available.
?includes[]=composite_products (under includes)includes[] query paramSupported values: brands, categories, suppliers, tags, variant_attributes, families, composite_components.
VersionedCollection / DataWrapper envelopedata + version {min, max} (list) / data + includesThe 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:

ParameterHydratesResolved from
brandsFull brand recordsThe family brand_id
categoriesFull category recordsThe family category_id
tagsFull tag recordsThe family tag_ids
suppliersFull supplier recordsThe supplier_id of each product's suppliers[]
variant_attributesFull variant attribute records (e.g. "Color", "Size")The family variant_attribute_ids
customizationsFull customization-field recordsThe family customization_ids
familiesThe full product_family object for each returned productEach product's family_id
composite_componentsThe full member products that make up a compositeThe composite_bom[].product_id of composite families

Note: Not every parameter applies to every endpoint. families is most useful on the product endpoints (GET /products and GET /products/{product_id}), where it loads the family each product belongs to. composite_components applies to the family endpoints and only returns data for COMPOSITE families. Unknown values in includes[] 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

The 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:

BucketFieldsBehaviour
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, compositeDescribe 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_activeDiffer per variant. Each member must have a unique sku and a unique combination of variant_definitions.
Accepted in both, but per-member winsthe per-member fields above also exist at the top levelWhen 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_suppliers alongside variants is 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

The 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 fieldNew fieldNotes
name, description, brand_id, product_type_id, product_category_idname, 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)classificationNow an explicit, required enum: STANDARD, VARIANT, or COMPOSITE.
tag_idstag_ids (on the family)Unchanged in name, but set on the family.
track_inventory (2.1 common) / has_inventorytrack_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_amountprices {...}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_activeactive {in_store, ecwid}Object with explicit channels.
compositecomposite_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

🔒

Requires: products:write scope

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 old common block. Use it for name, description, brand_id, category_id, track_inventory, tag_ids, variant_attribute_ids, composite_bom, and customizations.
  • PATCH /api/2026-10/products/{product_id} — the old details block. Use it for active, account_code, prices, measurements, codes, suppliers, packaging, outlet_taxes, and outlet_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)

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, null is treated as false).
  • Key present with a value — the field is set to that value (for strings, "" is treated the same as null). 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_tax is "19.99", not 19.99. This preserves precision. Quantities and measurements (for example composite_components[].quantity) remain unquoted numbers.
  • No more variant_parent_id / has_variants. Determine structure from the family's classification. To find a product's family, call GET /products/{product_id}/family.
  • Family vs member is enforced. Sending a family field (such as name) on a member PATCH, 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) return 422 Unprocessable Entity, often with an error_fields object 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. Use POST /api/2026-10/products/generate_product_codes to 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}/images and POST /product_families/{id}/images, which accept either a JSON body ({ "type": "...", "value": "..." }) or a multipart/form-data upload in the image field.

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.