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_productstakes 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 legacysecurityblock (minimum_price,minimum_component_price) was replaced by rules in August 2026. Writes that includesecurityare rejected with a 400 naming the replacement rule. A bundle last saved before the change may still return itssecurityblock from GET; the next save, through the API or the app, migrates it torulesautomatically.
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 fromearliest_available. - Top-level totals are bundle figures.
totals.revenue,totals.units, andtotals.orderssum the per-bundle rows, so an order containing two different bundles counts toward each bundle'sorders. For distinct order counts, usetotals.aov.with_bundles.order_count. - Figures are net of refunds. A refund is subtracted from the day its order was placed;
totals.refundedshows 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.countdivided byorders_with_bundles.count + orders_without_bundles.count) and the AOV comparison as your effectiveness measures. - Bundles are keyed by
product_id.titleis 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.