# Cart API Integration

> Learn how to add flex bundles to the cart using Shopify's Cart API with line item properties.

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.

<aside class="docs-cta">
  <p><strong>Building a custom bundle frontend?</strong> Most of the serious brands on Flex Bundles started right here. If you are scoping a build-your-own or mix-and-match experience, book a 30-minute call with the founder and get your questions answered!</p>
  <p><a href="/demo" class="button button--sm">Book a call</a></p>
</aside>

## 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](https://flexbundles.com/docs/admin-api#create-a-flex-bundle). 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](#validating-submitted-prices) 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

```javascript
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

```javascript
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](#validating-submitted-prices).

### Request Structure

```javascript
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

```javascript
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`:

```javascript
"_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](https://flexbundles.com/docs/admin-api#bundle-rules).

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](#pricing-modes) 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](https://flexbundles.com/docs/admin-api#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](#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

```javascript
{
  "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:

```javascript
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](#avoid-money-amounts-in-attributes) 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](#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.

```json
{
  "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](https://flexbundles.com/docs/bundle-types#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.

```json
{
  "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](https://flexbundles.com/docs/exporting-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:

```javascript
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](#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](https://flexbundles.com/demo). Setup and custom builds are also available as scoped engagements, priced per project.
