Documentation

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 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. For all the ways to get data out of Flex Bundles, see Exporting Your 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.

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

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;
}
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 for how they work.

Create a flex bundle

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",
    "rules": { "minimum_price": 25 },
    "settings": { "title": "Build Your Own Kit" }
  }'
const { bundle } = await flexBundles("/flex-bundles", "POST", {
  product_id: "10309675680045",
  rules: { minimum_price: 25 },
  settings: { title: "Build Your Own Kit" },
});
result = flex_bundles("/flex-bundles", method: :post, body: {
  product_id: "10309675680045",
  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.
rules object No Cart and checkout guardrails for the bundle. See 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:

{
  "bundle": {
    "id": 1783440131288415,
    "active": true,
    "bundle_type": "flex",
    "parent": { "product_id": 10309675680045 },
    "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).

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 }'
await flexBundles("/flex-bundles/1783440131288415", "PATCH", { active: false });
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

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 }
    ]
  }'
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 },
  ],
});
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.
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.

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 }
    ]
  }'
await flexBundles("/fixed-bundles/1783369970901365", "PATCH", {
  components: [
    { product_id: "10309675221293", variant_id: "52199500808493", quantity: 3 },
  ],
});
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.

{
  "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 },
    "eligible_products": [
      { "variants": [52199500808493, 52199500972333], "min": 1, "max": 1 },
      { "variants": [52199501037869], "min": 3 }
    ]
  }
}
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.
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.

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

curl https://api.flexbundles.com/v1/flex-bundles \
  -H "Authorization: Bearer fxb_your_api_key"
const { bundles } = await flexBundles("/flex-bundles");
bundles = flex_bundles("/flex-bundles")["bundles"]

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

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

curl -X DELETE https://api.flexbundles.com/v1/flex-bundles/1783440131288415 \
  -H "Authorization: Bearer fxb_your_api_key"
await flexBundles("/flex-bundles/1783440131288415", "DELETE");
flex_bundles("/flex-bundles/1783440131288415", method: :delete)

Delete a fixed bundle:

curl -X DELETE https://api.flexbundles.com/v1/fixed-bundles/1783369970901365 \
  -H "Authorization: Bearer fxb_your_api_key"
await flexBundles("/fixed-bundles/1783369970901365", "DELETE");
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.

curl "https://api.flexbundles.com/v1/analytics?start=2026-07-01&end=2026-07-31" \
  -H "Authorization: Bearer fxb_your_api_key"
const analytics = await flexBundles("/analytics?start=2026-07-01&end=2026-07-31");
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.

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

{ "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.