Documentation

Cart API Integration

Flex bundles use Shopify's Cart API to add bundles to the cart with line item properties. This guide covers both pricing modes: Parent Price (bundle uses the parent variant's price) and Component Sum (bundle price is calculated from individual component prices).

Overview

All Flex bundles are added to the cart using the /cart/add.js endpoint with a POST request. The bundle configuration is passed through line item properties, specifically the _components property which contains a JSON-stringified array of component objects.

Pricing Modes

Each flex bundle has a pricing mode, set in the Pricing section of the bundle editor or with pricing_mode in the Admin API. The mode is stored on the bundle, not read from the cart request, so a tampered request can't switch a bundle into the mode that suits it. Your storefront's request must match the bundle's mode:

Mode pricing_mode Bundle price Your request
Parent Price "parent" The parent variant's price, less any _discount Component price values are ignored
Component Sum "sum" The sum of each component's price times its quantity Every component must include a price

Bundles created before pricing modes were introduced have no mode until you choose one. For those, the mode is still detected from each request: Component Sum when every component has a price, Parent Price otherwise. The editor shows a "Set a pricing mode" banner on these bundles; choose a mode and save.

Parent Price Mode

In this mode, the bundle uses the parent variant's price. You can optionally apply a discount percentage using the _discount property. Because _discount is sent from the browser, cap it with the Maximum discount rule.

Use case: Fixed-price bundles where the components are pre-selected.

Component Sum Mode

In this mode, the bundle price is calculated by summing up the individual component prices. Each component must include a price property. _discount is ignored.

Use case: Build-your-own bundles, customizable bundles, or bundles where the price varies based on selections.


Parent Price Mode

For bundles set to Parent Price, add the bundle with the parent variant ID and include an optional _discount percentage. Any price values in _components are ignored.

Request Structure

let formData = {
  "items": [
    {
      "id": 51618980823341, // Parent variant ID
      "quantity": 1,
      "properties": {
        "_discount": 15, // OPTIONAL: Discount percentage (e.g., 15 = 15% off)
        "_components": JSON.stringify([
          {
            "id": 12345678901,      // Component variant ID
            "quantity": 2,          // Component quantity
            "attributes": {         // OPTIONAL: Component-specific attributes
              "Color": "Blue",
              "Size": "Medium",
              "Gift Message": "Happy Birthday!"
            }
          },
          {
            "id": 12345678902,
            "quantity": 1,
            "attributes": {
              "Style": "Classic",
              "Engraving": "Your text here"
            }
          },
          {
            "id": 12345678903,
            "quantity": 1
            // No attributes - component with defaults
          }
        ]),
        // Display properties (visible to customer)
        "Item 1": "Sample Gift with Gift Message",
        "Item 2": "Sample Monogrammed Gift",
        "Item 3": "Sample Gift",
        // OPTIONAL: Bundle settings
        "_settings": JSON.stringify({
          "title": "Sample Bundle",  // Custom bundle title
          "image": "https://cdn.shopify.com/s/files/1/0766/2665/7581/files/theme_cover_image.jpg?v=1684349973"
        })
      }
    }
  ]
};

Full Example

let formData = {
  "items": [
    {
      "id": 51618980823341,
      "quantity": 1,
      "properties": {
        "_discount": 15,
        "_components": JSON.stringify([
          {
            "id": 12345678901,
            "quantity": 2,
            "attributes": {
              "Color": "Blue",
              "Size": "Medium",
              "Gift Message": "Happy Birthday!"
            }
          },
          {
            "id": 12345678902,
            "quantity": 1,
            "attributes": {
              "Style": "Classic",
              "Engraving": "Your text here"
            }
          },
          {
            "id": 12345678903,
            "quantity": 1
          }
        ]),
        "Item 1": "Sample Gift with Gift Message",
        "Item 2": "Sample Monogrammed Gift",
        "Item 3": "Sample Gift",
        "_settings": JSON.stringify({
          "title": "Sample Bundle",
          "image": "https://cdn.shopify.com/s/files/1/0766/2665/7581/files/theme_cover_image.jpg?v=1684349973"
        })
      }
    }
  ]
};

fetch(window.Shopify.routes.root + 'cart/add.js', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
  console.log('Bundle added:', data);
})
.catch((error) => {
  console.error('Error:', error);
});

Component Sum Mode

For bundles set to Component Sum, each component must include a price property. The total bundle price is calculated from the sum of all component prices multiplied by their quantities. If any component is missing its price, the bundle is not expanded and checkout is blocked with an error, rather than falling back to the parent's price.

Because these prices are submitted from the browser, you can set server-side rules that validate them before they are honored. See Validating Submitted Prices.

Request Structure

let formData = {
  "items": [
    {
      "id": 51618980823341, // Parent variant ID
      "quantity": 1,
      "properties": {
        "_components": JSON.stringify([
          {
            "id": 12345678901,      // Component variant ID
            "quantity": 2,          // Component quantity
            "price": 49.99,         // Component price (REQUIRED for sum mode)
            "attributes": {         // OPTIONAL: Component-specific attributes
              "Color": "Blue",
              "Size": "Medium",
              "Gift Message": "Happy Birthday!"
            }
          },
          {
            "id": 12345678902,
            "quantity": 1,
            "price": 29.99,
            "attributes": {
              "Style": "Classic",
              "Engraving": "Your text here"
            }
          },
          {
            "id": 12345678903,
            "quantity": 1,
            "price": 19.99
            // No attributes - component with defaults
          }
        ]),
        // RECOMMENDED: currency the price values above were captured in
        "_currency": window.Shopify.currency.active,
        "_currency_rate": String(window.Shopify.currency.rate),
        // Display properties (visible to customer)
        "Item 1": "Sample Gift with Gift Message",
        "Item 2": "Sample Monogrammed Gift",
        "Item 3": "Sample Gift",
        // OPTIONAL: Bundle settings
        "_settings": JSON.stringify({
          "title": "Sample Bundle",
          "image": "https://cdn.shopify.com/s/files/1/0766/2665/7581/files/theme_cover_image.jpg?v=1684349973"
        })
      }
    }
  ]
};

Full Example

let formData = {
  "items": [
    {
      "id": 51618980823341,
      "quantity": 1,
      "properties": {
        "_components": JSON.stringify([
          {
            "id": 12345678901,
            "quantity": 2,
            "price": 49.99,
            "attributes": {
              "Color": "Blue",
              "Size": "Medium",
              "Gift Message": "Happy Birthday!"
            }
          },
          {
            "id": 12345678902,
            "quantity": 1,
            "price": 29.99,
            "attributes": {
              "Style": "Classic",
              "Engraving": "Your text here"
            }
          },
          {
            "id": 12345678903,
            "quantity": 1,
            "price": 19.99
          }
        ]),
        "Item 1": "Sample Gift with Gift Message",
        "Item 2": "Sample Monogrammed Gift",
        "Item 3": "Sample Gift",
        "_settings": JSON.stringify({
          "title": "Sample Bundle",
          "image": "https://cdn.shopify.com/s/files/1/0766/2665/7581/files/theme_cover_image.jpg?v=1684349973"
        })
      }
    }
  ]
};

fetch(window.Shopify.routes.root + 'cart/add.js', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
  console.log('Bundle added:', data);
  // Total price: (49.99 * 2) + 29.99 + 19.99 = $149.96
})
.catch((error) => {
  console.error('Error:', error);
});

Multi-Currency Stores

Component price values are captured in whatever currency the customer is browsing in, but Shopify re-runs bundle pricing at checkout in the shop's base currency. In Component Sum mode, always send _currency and _currency_rate alongside _components:

"_currency": window.Shopify.currency.active,           // e.g. "AED"
"_currency_rate": String(window.Shopify.currency.rate) // e.g. "3.6725" ("1.0" in the shop currency)

The app divides each captured price by _currency_rate to normalize it to the shop currency, then re-expresses it in the currency of the current pricing context, so cart display and the charged amount both come out right. If _currency_rate is missing, prices are applied as-is: correct for shop-currency sessions, but customers browsing in another currency will be charged the raw number in the shop currency (e.g. an AED 968 bundle charged as $968).

Note that this conversion applies only to the numeric price values inside _components. Display text in attributes and visible properties is never converted.

Validating Submitted Prices

In Component Sum mode the price values come from the browser, which means a hand-crafted cart request could submit prices your storefront never offered. You don't have to trust them: every bundle supports server-side rules that validate the submitted payload, configured in the Rules section of the bundle editor or via the Admin API.

Three rules guard prices directly:

  • Minimum bundle price (minimum_price): a floor on the final bundle price, checked after any _discount is applied. Applies in both pricing modes.
  • Component pricing (component_pricing): a per-unit floor on each submitted component price, with options to allow a limited number of free ($0) components. Applies to Component Sum bundles.
  • Maximum discount (max_discount): a cap on the _discount percentage, from 0 (no discount allowed) up to, but not including, 100. Applies to Parent Price bundles.

Pinning the bundle's pricing mode matters here too: it keeps a tampered request from dropping a price to escape the component pricing rule, or adding prices to a Parent Price bundle.

Rules are enforced entirely server-side, in two layers. A payload that breaks a rule is never priced as submitted: the bundle is not processed, so the line falls back to the parent variant's real price, and a checkout validation blocks the order with an error naming the broken rule. Rule values are in your shop's currency; multi-currency carts are converted before the check.

Rules can also restrict component counts, line quantity, and which variants a bundle accepts. See Bundle Rules for the full list.


Property Reference

Line Item Properties

Property Type Required Description
_components String (JSON) Yes JSON-stringified array of component objects
_discount Number No Discount percentage (Parent Price mode only; ignored in Component Sum). E.g., 15 for 15% off. Capped by the max_discount rule when set
_currency String No ISO code of the currency the component price values were captured in (e.g. "AED"). Informational only
_currency_rate String Recommended (Component Sum) The presented currency's rate relative to the shop currency at the time the prices were captured, from Shopify.currency.rate. Lets the app convert prices correctly in multi-currency stores. "1" for shop-currency sessions
_settings String (JSON) No JSON-stringified settings object for bundle customization
_attributes String (JSON) No Fixed bundles only. JSON-stringified object of attributes applied to every component in the bundle. See Fixed Bundle Attributes
Custom properties String No Any other properties will be displayed to the customer (e.g., "Item 1": "Gift Box")

Component Object

Property Type Required Description
id Number Yes Shopify variant ID of the component product
quantity Number Yes Quantity of this component in the bundle
price Number Component Sum only Price of the component. Required on every component in Component Sum mode; ignored in Parent Price mode
attributes Object No Key-value pairs for component-specific attributes (customizations, options, etc.)

Settings Object

Property Type Required Description
title String No Custom title to display for the bundle in cart
image String No URL to a custom bundle image. Must be hosted on Shopify CDN

Understanding Properties

Hidden vs. Visible Properties

Properties that start with an underscore (_) are hidden from the customer in the cart and checkout. These are used for bundle configuration:

  • _components - Bundle component data
  • _discount - Discount percentage
  • _currency / _currency_rate - Currency the component prices were captured in
  • _settings - Bundle display settings

Properties without an underscore are visible to the customer:

  • "Item 1": "Blue T-Shirt (Size M)" - Shown in cart
  • "Gift Message": "Happy Birthday!" - Shown in cart

Component Attributes

The attributes object within each component allows you to pass customization data that's specific to that component. This is useful for:

  • Product options (Color, Size, Material)
  • Personalization (Engraving, Monogram, Gift Message)
  • Custom configurations
{
  "id": 12345678901,
  "quantity": 1,
  "attributes": {
    "Color": "Navy Blue",
    "Size": "Large",
    "Monogram": "JKS",
    "Gift Wrap": "Yes"
  }
}

Fixed Bundle Attributes

Fixed bundles expand from the configuration saved in the app, so there is no _components array to carry per-component attributes. Instead, pass an _attributes property when adding the parent variant to the cart. Every key/value pair is added to each component line in the bundle:

await fetch('/cart/add.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: [{
      id: 12345678901, // the fixed bundle parent variant
      quantity: 1,
      properties: {
        _attributes: JSON.stringify({
          "Gift Message": "Happy Birthday!",
          "Gift Wrap": "Yes"
        })
      }
    }]
  })
});

Rules:

  • Applies to fixed bundles only. Flex bundles ignore _attributes; use the per-component attributes object instead.
  • Merged with any attributes set for the component in the app. If both define the same key, the _attributes value is used.
  • Values must be strings. Non-string values, empty keys, and the reserved _flex_bundle key are ignored.
  • If the JSON is malformed, the property is ignored and the bundle still expands normally.
  • The money amounts guidance below applies here as well.

Avoid money amounts in attributes

Attribute values (and visible line item properties) are frozen display strings. The app never parses, converts, or re-formats them; whatever you write at add-to-cart time is what appears in the cart, at checkout, on the order confirmation, and in notification emails. That makes absolute money amounts a trap in multi-currency stores: a string like "Original Price": "$185.00" captured in one currency will sit unchanged next to totals rendered in another (see Multi-Currency Stores, which only applies to the _components price values, not to attribute text).

Recommendations:

  • Prefer currency-proof text: percentages ("You Save": "15%") or counts ("Includes": "4 items") instead of amounts.
  • If you do show amounts, format them in the customer's presented currency (using Shopify.currency or your theme's money filter) so they at least match the cart at the moment of capture. Be aware they will not re-convert if the customer switches currency or when checkout settles in the shop currency.

Where Bundle Data Lives

A bundle takes three different shapes on its way from add-to-cart to fulfillment. Integrations (B2B pricing apps, discount apps, fulfillment software, analytics) each read from one of these surfaces, and reading from the wrong one is the most common source of integration confusion.

1. The add-to-cart request

Your storefront POSTs the parent variant to /cart/add.js with the selected components in the _components line item property, as described throughout this guide. This request is only visible to code that sends or intercepts it. Other apps should never depend on it.

2. The storefront cart (/cart.js and the Liquid cart object)

On the storefront, the bundle remains a single line item: the parent variant, carrying the _components property you submitted, plus a Shopify-native has_components: true flag. The components are not separate line items in the cart, even though the Cart Transform is already registered against it. Expansion happens at checkout, not in the cart.

{
  "items": [
    {
      "variant_id": 45678901234567,
      "quantity": 1,
      "has_components": true,
      "properties": {
        "_components": "[{\"id\":41234567890123,\"quantity\":2},{\"id\":41234567890456,\"quantity\":1}]"
      }
    }
  ]
}

This shape is stable: it persists across page reloads and cart updates for the life of the cart.

What this means for storefront apps (wholesale pricing, discounting, cart upsells): there are no component line items to exclude, because they do not exist yet. To keep a bundle out of another app's cart-level pricing or discount logic, target the parent product or variant, or detect a bundle line generically via has_components or the presence of the _components property.

3. The order (post-purchase)

On the order object, the expansion is complete and the components are separate, real line items. Each one carries a _flex_bundle property by default, whose value is the bundle parent's product GID (e.g. gid://shopify/Product/8812345678901), plus any per-component attributes you passed at add-to-cart. This is what fulfillment software, 3PLs, and order-level integrations see: actual products with actual SKUs and quantities.

Components added by Expand bundle on order edits do not carry the _flex_bundle property, because Shopify's Order Editing API cannot set it. Use the order's $app:bundle_breakdown metafield to identify bundle lines on those orders.

{
  "line_items": [
    {
      "variant_id": 41234567890123,
      "quantity": 2,
      "properties": [{ "name": "_flex_bundle", "value": "gid://shopify/Product/8812345678901" }]
    },
    {
      "variant_id": 41234567890456,
      "quantity": 1,
      "properties": [{ "name": "_flex_bundle", "value": "gid://shopify/Product/8812345678901" }]
    }
  ]
}

Orders containing a bundle are also tagged flex-bundles-has-bundle and carry a $app:bundle_breakdown metafield, so bundle orders are queryable in order search, Segments, and Flow. See Exporting Your Data for the full order-level data reference.

Which surface should your integration read?

Integration Read from Identify bundles by
Wholesale / B2B pricing, discount apps (storefront) Cart Parent product/variant, or has_components: true
Fulfillment, 3PL, OMS Order line items _flex_bundle property on each component
Analytics, flows, segmentation Order flex-bundles-has-bundle tag, $app:bundle_breakdown metafield
Theme code rendering the cart Cart _components property on the parent line

Image Requirements

When using a custom bundle image in _settings, the image must be hosted on Shopify's CDN. You can upload images to your Shopify store via:

  1. Settings > Files - Upload files and get the CDN URL
  2. Product images - Use an existing product image URL
  3. Theme assets - Reference theme asset URLs

Valid CDN URL format:

https://cdn.shopify.com/s/files/1/XXXX/XXXX/XXXX/files/your-image.jpg

Error Handling

Always implement proper error handling when adding bundles to the cart:

fetch(window.Shopify.routes.root + 'cart/add.js', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(formData)
})
.then(response => {
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
})
.then(data => {
  if (data.status === 422) {
    // Handle validation errors
    console.error('Validation error:', data.description);
  } else {
    // Success
    console.log('Bundle added successfully:', data);
    // Optionally refresh cart or update UI
  }
})
.catch((error) => {
  console.error('Error adding bundle to cart:', error);
});

Common Issues

Bundle not processing correctly

  • Ensure _components is properly JSON-stringified
  • Verify all component variant IDs are valid and in stock
  • Check that the parent variant ID exists and is set up as a Flex Bundle

Bundle priced at the parent variant's price, or checkout blocked

  • For a Component Sum bundle, check that every component in _components has a price. A missing price blocks checkout with "a component is missing its price"
  • The payload is likely breaking one of the bundle's rules (minimum bundle price, component price floor, maximum discount, component counts, eligible products)
  • Check the Rules section of the bundle in the app, and see Validating Submitted Prices

Discount not applying

  • The _discount property only works in Parent Price mode. Check the bundle's pricing mode in the editor
  • If the bundle has a Maximum discount rule, a _discount above it blocks checkout
  • Send a plain number, like 15 or "15". Shopify stores line item properties as strings, so either works, but anything else in the value (a % sign, spaces, or other characters, e.g. "15%") is treated as no discount
  • Discount is a percentage, not a fixed amount

Custom image not showing

  • Image must be hosted on Shopify's CDN
  • Verify the URL is publicly accessible
  • Check for typos in the URL

Need a hand?

If you are building a custom storefront on this API and want a second pair of eyes on the payload, pricing mode, or rules, book a call. Setup and custom builds are also available as scoped engagements, priced per project.