# Bundles Admin API

> Create, update, and delete flex and fixed bundles programmatically with the Flex Bundles Admin API.

The Bundles Admin API lets you manage bundles programmatically from your own scripts and systems: sync bundles from a PIM, build seasonal bundles in bulk, or wire bundle creation into your existing catalog tooling. It covers the same operations as the app's bundle editor, so anything you create here behaves exactly like a bundle built in the app. It also exposes your [bundle performance data](#analytics) for dashboards and spreadsheets.

This API manages bundle configuration and reads analytics. To add bundles to the cart on your storefront, see [Cart API Integration](https://flexbundles.com/docs/cart-api-integration). For all the ways to get data out of Flex Bundles, see [Exporting Your Data](https://flexbundles.com/docs/exporting-data).

## Base URL

```
https://api.flexbundles.com/v1
```

## Authentication

Every request needs an API key sent as a bearer token:

```
Authorization: Bearer fxb_your_api_key
```

Generate your key from the Settings page in the Flex Bundles app. The key is shown once at generation time and only a hash is stored, so copy it somewhere safe. Regenerating or revoking a key stops the old key working immediately. Uninstalling the app revokes the key too, so after a reinstall you need to generate a new one.

Keep the key server-side. It grants full bundle management for your store, so never ship it in theme code or client-side JavaScript.

## Making Requests

The examples in this guide show cURL, Node.js, and Ruby. The Node.js and Ruby examples use these small helpers, which need nothing beyond the standard runtime (Node.js 18+, any recent Ruby):

```javascript
const API = "https://api.flexbundles.com/v1";
const KEY = process.env.FLEX_BUNDLES_API_KEY;

async function flexBundles(path, method = "GET", body) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers: {
      "Authorization": `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${res.status}: ${json.error}`);
  return json;
}
```

```ruby
require "net/http"
require "json"

API = "https://api.flexbundles.com/v1"
KEY = ENV.fetch("FLEX_BUNDLES_API_KEY")

def flex_bundles(path, method: :get, body: nil)
  uri = URI("#{API}#{path}")
  request = Net::HTTP.const_get(method.capitalize).new(uri)
  request["Authorization"] = "Bearer #{KEY}"
  request["Content-Type"] = "application/json"
  request.body = body.to_json if body

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end

  json = JSON.parse(response.body)
  raise "#{response.code}: #{json["error"]}" unless response.is_a?(Net::HTTPSuccess)
  json
end
```

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/v1/flex-bundles` | List flex bundles |
| POST | `/v1/flex-bundles` | Create a flex bundle |
| GET | `/v1/flex-bundles/{id}` | Get one flex bundle |
| PUT or PATCH | `/v1/flex-bundles/{id}` | Update a flex bundle |
| DELETE | `/v1/flex-bundles/{id}` | Delete a flex bundle |
| GET | `/v1/fixed-bundles` | List fixed bundles |
| POST | `/v1/fixed-bundles` | Create a fixed bundle |
| GET | `/v1/fixed-bundles/{id}` | Get one fixed bundle |
| PUT or PATCH | `/v1/fixed-bundles/{id}` | Update a fixed bundle |
| DELETE | `/v1/fixed-bundles/{id}` | Delete a fixed bundle |
| GET | `/v1/analytics` | Bundle performance data (daily series + totals) |

All request and response bodies are JSON. IDs (bundle, product, variant) are numeric Shopify IDs without the `gid://` prefix.

---

## Flex Bundles

Flex bundles attach to a product and let customers build their own bundle on your storefront. See [Bundle Types](https://flexbundles.com/docs/bundle-types) for how they work.

### Create a flex bundle

```bash
curl -X POST https://api.flexbundles.com/v1/flex-bundles \
  -H "Authorization: Bearer fxb_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "10309675680045",
    "pricing_mode": "sum",
    "rules": { "minimum_price": 25 },
    "settings": { "title": "Build Your Own Kit" }
  }'
```

```javascript
const { bundle } = await flexBundles("/flex-bundles", "POST", {
  product_id: "10309675680045",
  pricing_mode: "sum",
  rules: { minimum_price: 25 },
  settings: { title: "Build Your Own Kit" },
});
```

```ruby
result = flex_bundles("/flex-bundles", method: :post, body: {
  product_id: "10309675680045",
  pricing_mode: "sum",
  rules: { minimum_price: 25 },
  settings: { title: "Build Your Own Kit" },
})
bundle = result["bundle"]
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `product_id` | string or number | Yes | The parent product. One flex bundle per product. |
| `active` | boolean | No | Defaults to `true`. |
| `pricing_mode` | string | Recommended | `"parent"` or `"sum"`. How the bundle is priced; see [Pricing Modes](https://flexbundles.com/docs/cart-api-integration#pricing-modes). When omitted, the mode is detected from each cart request. Set it so your pricing rules are always enforced as expected. |
| `rules` | object | No | Cart and checkout guardrails for the bundle. See [Bundle Rules](#bundle-rules). |
| `settings` | object | No | Optional presentation settings (`title`, `image`, `walkthrough_url`). |

Response is `201 Created` with the full bundle. The `id` is generated by the API:

```json
{
  "bundle": {
    "id": 1783440131288415,
    "active": true,
    "bundle_type": "flex",
    "parent": { "product_id": 10309675680045 },
    "pricing_mode": "sum",
    "rules": { "minimum_price": 25 },
    "settings": { "title": "Build Your Own Kit" }
  }
}
```

### Update a flex bundle

PUT and PATCH behave identically: send only the fields you want to change. `rules`, when provided, replaces the whole rules block; send `"rules": {}` to remove every rule (see [Bundle Rules](#bundle-rules)).

```bash
curl -X PATCH https://api.flexbundles.com/v1/flex-bundles/1783440131288415 \
  -H "Authorization: Bearer fxb_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'
```

```javascript
await flexBundles("/flex-bundles/1783440131288415", "PATCH", { active: false });
```

```ruby
flex_bundles("/flex-bundles/1783440131288415", method: :patch, body: { active: false })
```

`product_id` cannot be changed on a flex bundle. Delete the bundle and create a new one on the other product instead.

---

## Fixed Bundles

Fixed bundles attach to a specific variant with a pre-set component list. Adding the parent variant to the cart automatically includes the components.

### Create a fixed bundle

```bash
curl -X POST https://api.flexbundles.com/v1/fixed-bundles \
  -H "Authorization: Bearer fxb_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "10313045967149",
    "variant_id": "52212525334829",
    "components": [
      { "product_id": "10309675221293", "variant_id": "52199500808493", "quantity": 2 },
      { "product_id": "10309675286829", "variant_id": "52199500972333", "quantity": 1 }
    ]
  }'
```

```javascript
const { bundle } = await flexBundles("/fixed-bundles", "POST", {
  product_id: "10313045967149",
  variant_id: "52212525334829",
  components: [
    { product_id: "10309675221293", variant_id: "52199500808493", quantity: 2 },
    { product_id: "10309675286829", variant_id: "52199500972333", quantity: 1 },
  ],
});
```

```ruby
result = flex_bundles("/fixed-bundles", method: :post, body: {
  product_id: "10313045967149",
  variant_id: "52212525334829",
  components: [
    { product_id: "10309675221293", variant_id: "52199500808493", quantity: 2 },
    { product_id: "10309675286829", variant_id: "52199500972333", quantity: 1 },
  ],
})
bundle = result["bundle"]
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `product_id` | string or number | Yes | The parent product. |
| `variant_id` | string or number | Yes | The parent variant. Must belong to `product_id`. One fixed bundle per variant. |
| `components` | array | Yes | At least one component (see below). |
| `active` | boolean | No | Defaults to `true`. |
| `rules` | object | No | Only `max_line_quantity` applies to fixed bundles. See [Bundle Rules](#bundle-rules). |
| `settings` | object | No | Optional presentation settings (`title`, `image`, `walkthrough_url`). |

Each component:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `variant_id` | string or number | Yes | The component variant. Use `0` to match any variant of `product_id`. |
| `product_id` | string or number | No | The component's product. Recommended. |
| `quantity` | integer | No | Defaults to `1`. Must be at least `1`. |
| `attributes` | object | No | Extra line item properties stamped on this component in the cart. |

Response is `201 Created` with the full bundle.

Note: saving a fixed bundle also syncs inventory behavior on the parent variant. If every component is in stock the parent variant's inventory tracking is turned off (it is a virtual item holding no stock of its own); if any component is out of stock the parent is set out of stock so the bundle cannot be oversold.

### Update a fixed bundle

Send only what changes. `components` and `rules`, when provided, each replace their whole block.

```bash
curl -X PATCH https://api.flexbundles.com/v1/fixed-bundles/1783369970901365 \
  -H "Authorization: Bearer fxb_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "components": [
      { "product_id": "10309675221293", "variant_id": "52199500808493", "quantity": 3 }
    ]
  }'
```

```javascript
await flexBundles("/fixed-bundles/1783369970901365", "PATCH", {
  components: [
    { product_id: "10309675221293", variant_id: "52199500808493", quantity: 3 },
  ],
});
```

```ruby
flex_bundles("/fixed-bundles/1783369970901365", method: :patch, body: {
  components: [
    { product_id: "10309675221293", variant_id: "52199500808493", quantity: 3 },
  ],
})
```

A fixed bundle's parent cannot be changed: `product_id` and `variant_id` are fixed at creation. To move a bundle to a different variant, delete it and create a new one.

---

## Bundle Rules

Rules put guardrails on what a bundle accepts in the cart. They are enforced twice: in the cart, where an add that breaks a rule is refused, and again at checkout by a cart validation that blocks the order with the rule's message. Because flex bundle payloads are assembled in the browser, rules are your protection against hand-crafted cart requests. They are the same rules the app's bundle editor manages.

Send rules as a `rules` object on create or update. A rule is active only when present; there are no zero or `null` "off" values. On update the block is replaced wholesale, not merged: send the complete set of rules you want, or `"rules": {}` to remove them all.

```json
{
  "rules": {
    "minimum_price": 45.0,
    "max_line_quantity": 3,
    "component_count": { "min": 2, "max": 6, "max_quantity_each": 2 },
    "component_pricing": { "minimum": 8.5, "allow_zero": true, "max_zero_units": 2 },
    "max_discount": 20,
    "eligible_products": [
      { "variants": [52199500808493, 52199500972333], "min": 1, "max": 1 },
      { "variants": [52199501037869], "min": 3 }
    ],
    "require_components": true
  }
}
```

| Rule | Applies to | Description |
|------|------------|-------------|
| `minimum_price` | Flex | Floor on the final bundle price, checked after any `_discount` is applied. Number greater than `0`. |
| `max_line_quantity` | Flex and fixed | Cap on the quantity of the bundle's cart line. Integer, at least `1`. |
| `component_count` | Flex | Bounds on the component units in one bundle, regardless of the cart line's quantity (use `max_line_quantity` for that dimension). `min` and `max` bound the total; `max_quantity_each` caps the units of any single variant. Each an integer of at least `1`; set at least one of the three. |
| `component_pricing` | Flex | Guards on the component prices your storefront sends. `minimum` (number greater than `0`) is a per-unit price floor. `allow_zero` (boolean) permits zero-priced components despite the floor, and `max_zero_units` (integer, requires `allow_zero`) caps how many zero-priced units one bundle may hold. Set at least one. |
| `max_discount` | Flex | Cap on the `_discount` percentage sent with a Parent Price bundle. Number from `0` (no discount allowed) up to, but not including, `100`. Component Sum bundles ignore `_discount`, so the rule doesn't apply to them. |
| `eligible_products` | Flex | Restricts which variants the bundle accepts. An array of groups, each `{ "variants": [...], "min": n, "max": n }`. When any group exists, every component must appear in one of the groups. A group's optional `min` and `max` bound how many units the bundle takes from that group's list; `min` applies even when the payload contains none of them, and an omitted bound means any quantity. |
| `require_components` | Flex | Rejects adding the bundle to the cart when the line has no `_components` property, or one that can't be read, and blocks checkout if such a line gets there another way, so the parent product never sells on its own (for example after a quick-add button or a cart link adds it). Only `true` is accepted; remove the rule to turn it off. A line with no `_components` still goes through when the bundle has Buy Again enabled and the logged-in customer has a saved selection for it. Draft orders with the bare parent show the rule's message to staff as a warning but can still be created. |

Fixed bundles have merchant-authored components and parent pricing, so only `max_line_quantity` applies there; the other rules are rejected with a 400.

Good to know:

- Money values (`minimum_price`, `component_pricing.minimum`) are in your shop's currency. Multi-currency carts are converted before the check.
- `eligible_products` takes numeric variant IDs only, and each variant may appear in only one group. At most 10 groups and 200 variants per bundle; to allow a whole product, list each of its variants.
- **Migrating from `security`:** the legacy `security` block (`minimum_price`, `minimum_component_price`) was replaced by rules in August 2026. Writes that include `security` are rejected with a 400 naming the replacement rule. A bundle last saved before the change may still return its `security` block from GET; the next save, through the API or the app, migrates it to `rules` automatically.

---

## Listing and Reading

```bash
curl https://api.flexbundles.com/v1/flex-bundles \
  -H "Authorization: Bearer fxb_your_api_key"
```

```javascript
const { bundles } = await flexBundles("/flex-bundles");
```

```ruby
bundles = flex_bundles("/flex-bundles")["bundles"]
```

`GET /v1/flex-bundles` and `GET /v1/fixed-bundles` return lightweight summaries:

```json
{
  "bundles": [
    {
      "id": 1783369970901365,
      "product_id": 10309675286829,
      "variant_id": 52199500972333,
      "active": true,
      "created_at": "2026-07-02T18:20:11.000Z",
      "updated_at": "2026-07-08T09:14:32.000Z"
    }
  ]
}
```

`variant_id` appears on fixed bundles only. Fetch `GET /v1/{type}-bundles/{id}` for the full configuration, which returns the same shape as create and update responses plus `created_at` and `updated_at`.

## Deleting

Delete a flex bundle:

```bash
curl -X DELETE https://api.flexbundles.com/v1/flex-bundles/1783440131288415 \
  -H "Authorization: Bearer fxb_your_api_key"
```

```javascript
await flexBundles("/flex-bundles/1783440131288415", "DELETE");
```

```ruby
flex_bundles("/flex-bundles/1783440131288415", method: :delete)
```

Delete a fixed bundle:

```bash
curl -X DELETE https://api.flexbundles.com/v1/fixed-bundles/1783369970901365 \
  -H "Authorization: Bearer fxb_your_api_key"
```

```javascript
await flexBundles("/fixed-bundles/1783369970901365", "DELETE");
```

```ruby
flex_bundles("/fixed-bundles/1783369970901365", method: :delete)
```

Returns `{ "deleted": true, "id": 1783369970901365 }`. Deleting removes the bundle configuration and, for fixed bundles, restores the parent variant's inventory tracking and cleans up component references. Orders already placed are unaffected.

## Analytics

`GET /v1/analytics` returns your bundle performance data as a daily series plus totals for the requested range: revenue, units, and orders per bundle, and an AOV comparison between orders with and without bundles. Point a scheduled script at it to feed a Google Sheet, a BI tool, or a data warehouse.

```bash
curl "https://api.flexbundles.com/v1/analytics?start=2026-07-01&end=2026-07-31" \
  -H "Authorization: Bearer fxb_your_api_key"
```

```javascript
const analytics = await flexBundles("/analytics?start=2026-07-01&end=2026-07-31");
```

```ruby
analytics = flex_bundles("/analytics?start=2026-07-01&end=2026-07-31")
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `start` | string | No | First day of the range, `YYYY-MM-DD`. Defaults to a 30-day window ending at `end`. A single request may span at most 366 days. |
| `end` | string | No | Last day of the range, `YYYY-MM-DD`. Defaults to today. Clamped to today. |

Dates are interpreted in your shop's timezone, matching the in-app dashboard and Shopify's own reports.

```json
{
  "currency": "USD",
  "start": "2026-07-01",
  "end": "2026-07-31",
  "earliest_available": "2024-03-12",
  "days": [
    {
      "date": "2026-07-01",
      "bundles": [
        { "product_id": 10309675680045, "title": "Hydration Kit", "revenue": 1240.5, "units": 62, "orders": 31 }
      ],
      "orders_with_bundles": { "count": 31, "value": 4100.0 },
      "orders_without_bundles": { "count": 210, "value": 9800.0 }
    }
  ],
  "totals": {
    "revenue": 38450.75,
    "refunded": 412.00,
    "units": 1922,
    "orders": 961,
    "bundles": [
      { "product_id": 10309675680045, "title": "Hydration Kit", "revenue": 38450.75, "units": 1922, "orders": 961 }
    ],
    "aov": {
      "with_bundles": { "aov": 132.26, "order_count": 961, "total_value": 127101.86 },
      "without_bundles": { "aov": 46.67, "order_count": 6510, "total_value": 303821.70 },
      "difference": 85.59,
      "percent_change": 183.39
    }
  }
}
```

Every date in the range gets a row; days with no recorded orders are zero-filled. `days[].bundles` only lists bundles that sold that day, while `totals.bundles` aggregates the whole range, sorted by revenue. `earliest_available` is the first day with recorded data for your store. `aov.percent_change` is `null` when the range has no non-bundle orders.

Good to know:

- **Retention is unlimited; coverage starts at `earliest_available`.** Recorded data never expires. History reaches back to when you installed Flex Bundles, plus roughly 90 days before that as a comparison baseline, loaded by the history import on the Analytics page. A single call may span at most 366 days, so page by year for multi-year pulls, starting from `earliest_available`.
- **Top-level totals are bundle figures.** `totals.revenue`, `totals.units`, and `totals.orders` sum the per-bundle rows, so an order containing two different bundles counts toward each bundle's `orders`. For distinct order counts, use `totals.aov.with_bundles.order_count`.
- **Figures are net of refunds.** A refund is subtracted from the day its order was placed; `totals.refunded` shows how much was subtracted from the bundle figures. Order counts are never reduced, so a fully refunded order still counts as an order. Bundle units only drop when a whole bundle's worth of components is refunded.
- **There is no storefront conversion rate.** Metrics come from orders, not page views. Use attach rate (`orders_with_bundles.count` divided by `orders_with_bundles.count + orders_without_bundles.count`) and the AOV comparison as your effectiveness measures.
- **Bundles are keyed by `product_id`.** `title` is the current name; renaming a bundle keeps its history.
- **Poll at a sane cadence.** Data is daily-granularity and updates as orders arrive; responses are cacheable for 5 minutes, and polling more than every 15 minutes buys you nothing.

## Errors

Errors return a JSON body with a single `error` message:

```json
{ "error": "This product already has a flex bundle. Edit the existing bundle instead of creating a new one." }
```

| Status | Meaning |
|--------|---------|
| 400 | Invalid input: malformed JSON, non-numeric ID, bad field type or value. |
| 401 | Missing or invalid API key, or the app's access to your store needs re-authorizing (open the app in Shopify admin, then retry). |
| 402 | An active Flex Bundles subscription is required. |
| 404 | No bundle with that ID (or it is a different bundle type). |
| 405 | HTTP method not supported on that path. |
| 409 | The product or variant already has a bundle of that type. |
| 422 | The product or variant does not exist, the variant does not belong to the product, or you tried to change a bundle's parent (`product_id` or `variant_id`). |

## Good to Know

- Bundles created through the API appear in the Flex Bundles app immediately, and vice versa. There is one source of truth.
- Send write requests sequentially rather than in parallel. Concurrent writes from multiple sources can race on the bundle registry.
- There is one API key per store. Rotate it any time from the app; the old key stops working the moment a new one is generated.
