> For the complete documentation index, see [llms.txt](https://docs.elitebundleapp.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.elitebundleapp.com/reference/sdk.md).

# Bundle SDK

Reference for the Elite Bundle Builder SDK: live bundle state, steps, cart, discount and actions for custom Shopify bundle storefronts.

Live bundle state and actions for custom storefront UIs. One SDK covers **both** bundle types: build-your-own bundles and multi-option bundles.

Available after the `elite:byob:ready` event fires. `null` when no bundle is on the current page, and the whole `sdk` object is absent unless SDK access is enabled in **Settings → Developer SDK**.

```js
const sdk = window.eliteBundle.sdk?.byob;
```

> Read the state fresh each time you use it. The object is replaced on every update, so a reference you stashed earlier will go stale.

***

## Build-your-own and multi-option, one shape

A multi-option bundle is a bundle whose every step holds exactly one item. That is why there is no separate MOB SDK — the same `steps`, `cart` and `checkout` describe both:

|              | Build your own                      | Multi-option                                            |
| ------------ | ----------------------------------- | ------------------------------------------------------- |
| `steps`      | the steps the shopper moves through | the **options** the bundle is built from                |
| `cart`       | everything the shopper picked       | one line per option                                     |
| pick an item | `addToCart` (adds / increments)     | `selectOne` (replaces that option's pick)               |
| add to cart  | `checkout()`                        | the theme's own Add to cart button, which the app hooks |

Read `steps[i].optionMode` to tell how an option is filled: `"single_product_options"` means the shopper builds a variant from one product's own options (Colour → Size); anything else means they pick from a list.

***

## State

### `bundleId`

**`string`** — Shopify product ID of the bundle.

### `steps`

**`Step[]`** — the steps that show for the current picks (options, for a multi-option bundle), each with its fetched products. A step whose **Show this step** condition doesn't hold is left out, and changes as the shopper picks. A step set to *Show it locked* stays in, with `locked: true` and `unlockRemaining` (items, or money in the shopper's currency, still needed): show its products, but `canAddToStep` is `false` for it and its picks and minimum don't count.

```ts
interface Step {
  id: string;
  title: TranslatableString;        // see "Translated text" below
  description?: TranslatableString;
  optionMode?: "products" | "single_product_options";
  products: Product[];
  rules: {
    limit: {
      by: "none" | "items" | "amount"; // "none": the numbers below do not apply
      min: number;                      // 0 = no minimum
      max: number;                      // 0 = no maximum
    };
    onePerProduct: boolean;
    gift: boolean;                      // free gift step: its items are free
    giftLabel?: TranslatableString;     // shown on gift items in the bundle cart
    showWhen: {                       // "step 1 has at least 6 items from these products"
      stepId: string;                   // an earlier step; "" = always shown
      op: "atLeast" | "fewerThan";
      by: "items" | "amount";           // amount in the shop's currency
      value: number;
      products: string[];               // product GIDs; empty = any product
      lock?: boolean;                   // until met: shown locked, not hidden
    };
  };
}
```

The limits live under `rules`, not on the step itself. An `"amount"` limit is in the shop's currency, before discounts; compare it with cart prices times `window.Shopify.currency.rate`.

```ts
interface Product {
  id: string;
  title: string;
  handle: string;
  stepId: string;          // the step this product belongs to
  sourceProductId: string; // the real Shopify product id — see the note below
  featuredImage?: { url: string; altText?: string };
  priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
  options?: Array<{ name: string; values: string[] }>;
  variants: Variant[];
}

interface Variant {
  id: string;
  title: string;
  price: string;              // decimal string, e.g. "29.99"
  compareAtPrice?: string;
  available: boolean;
  sku?: string;
  image?: { url: string; altText?: string };
  selectedOptions?: Array<{ name: string; value: string }>;
}
```

> **When `showVariantAsIndividualCard` is on**, each variant is surfaced as its own entry with a synthetic `id` and a title suffixed by the variant name. Use `sourceProductId` whenever you need the real product.

### `cart`

**`CartItem[]`** — the current selection.

```ts
interface CartItem {
  id: string;              // unique per step + variant + add-on answers
  stepId: string;
  productId: string;
  variantId: string;
  productHandle: string;
  title: string;
  variantTitle: string;    // "" when the product has no real variants
  sku?: string;            // absent when the variant has none
  imageUrl: string;        // "" when there is no image
  price: number;
  compareAtPrice: number | null;
  quantity: number;
  isGift?: boolean;        // from a free gift step: free, counts toward nothing
  addons?: Record<string, string | File>; // add-on answers: add-on id → text, choice id, "1" (ticked), or the shopper's image
}
```

### `cartTotal` · `cartTotalFormatted` · `cartCount`

**`number`** · **`string`** · **`number`** — the sum of price × quantity, that sum run through the store's money format, and the total item count. Free gift items (`isGift`) are left out of all three: they cost nothing and count toward no tier or limit.

> **On a subscription**, `cartTotal` and `cartTotalFormatted` are the bundle's total on the chosen plan, before the bundle discount. This applies while the shopper has a subscription selected in the bundle's purchase-options picker (see [Subscriptions](/create-bundles/subscriptions.md)). Tier progress uses the same total. The `cartTotal` in the `elite:byob:cart-change` event follows the same rule.

### `addOnsTotal`

**`number`** — the add-on fees of the cart's items, in the shopper's currency. It is not in `cartTotal`: add it for the amount the shopper pays. The bundle discount never applies to it.

### `canCheckout`

**`boolean`** — every requirement is met: the bundle-wide minimum, plus the minimum of each step that is showing. Gate your add button on this.

### `bundleAddOns` · `details` · `detailsComplete`

**`Array<{ id, title, type, required, choices, showIf? }>`** · **`Record<string, string>`** · **`boolean`** — the bundle add-ons, filled in once for the whole bundle (**Offer → Add-ons**: gift message, To & From, delivery date), titles and choices in the shopper's language; what the shopper has filled in so far (add-on id → text, a choice's id, or `"1"` for a ticked checkbox); and whether every required one is filled in. `type` is one of `text`, `textarea`, `email`, `date` (`YYYY-MM-DD`), `checkbox`, `radio`.

`showIf` (`{ addOnId, choiceId? }`) marks one that shows only while the add-on `addOnId`, earlier in the list and itself shown, is filled in (a ticked box, any text, an address that looks like an email, a date) or, with `choiceId`, set to that choice. Render it only then: while hidden it isn't required, so `detailsComplete` ignores it, and its answer isn't sent or charged even if `setDetails` passed one.

```js
const shown = [];
for (const a of sdk.bundleAddOns) {
  const p = a.showIf && shown.find((s) => s.id === a.showIf.addOnId);
  const v = p && sdk.details[p.id];
  if (!a.showIf || (p && (a.showIf.choiceId ? v === a.showIf.choiceId : Boolean(v?.trim())))) shown.push(a);
}
```

### `isTierMaxReached`

**`boolean`** — the cart has reached the highest quantity tier, so there is no further discount to unlock. Only meaningful for quantity-based tiers.

### `isLoading` · `error`

**`boolean`** · **`string | null`** — `error` is a single shopper-facing message; the diagnostic detail goes to the browser console.

### `isCheckingOut` · `checkoutError`

**`boolean`** · **`string | null`** — in-flight and failure state for the add to cart.

### `discountConfig`

**`DiscountSettings`** — the bundle's discount.

```ts
interface DiscountSettings {
  discountType: "none" | "tier";
  tierBasedOn?: "quantity" | "order_total";
  discountCalculationType?: "percentage" | "fixed_price" | "fixed_amount" | "per_item";
  tiers?: Array<{ id: string; minValue: number; discountValue: number }>;
}
```

`minValue` is a **count** when `tierBasedOn` is `"quantity"` and a **money amount** when it is `"order_total"`. A multi-option bundle's flat discount is expressed as a single tier with `minValue: 0`, so it always applies.

```js
// The next tier the shopper has not reached yet
const { tiers = [], tierBasedOn = "quantity" } = sdk.discountConfig;
const current = tierBasedOn === "quantity" ? sdk.cartCount : sdk.cartTotal;
const next = [...tiers].sort((a, b) => a.minValue - b.minValue)
                       .find((t) => t.minValue > current);
```

### `globalRules`

**`GlobalRules`** — the bundle-wide rules from Config → Rules → Whole bundle.

```ts
interface GlobalRules {
  limit: {
    by: "none" | "items" | "amount"; // "none": min and max do not apply
    min: number;                      // 0 = no minimum
    max: number;                      // 0 = no maximum
  };
  capAtMaxTier: boolean;              // add buttons lock at the top quantity tier
}
```

An `"amount"` limit is in the shop's currency, before discounts. You rarely need to check it yourself: `canCheckout` and `canAddToStep` already do. Read it to show a message such as "Spend $20 more":

```js
const { limit } = sdk.globalRules;
if (limit.by === "amount" && limit.min > 0) {
  const left = limit.min * window.Shopify.currency.rate - sdk.cartTotal;
  if (left > 0) hint.textContent = `Spend ${sdk.formatPrice(left)} more`;
}
```

***

## Actions

### `formatPrice(amount)`

```ts
formatPrice(amount: number): string
```

Formats an amount using the store's money format.

### `addToCart(product, variantId, stepId?, addons?, quantity?)`

```ts
addToCart(
  product: Product,
  variantId: string,
  stepId?: string,
  addons?: Record<string, string | File>, // add-on id → answer (a File for an image upload)
  quantity?: number,               // default 1; the limits may let fewer in
): void
```

Adds one unit, or `quantity`. `stepId` defaults to `product.stepId`. With `addons`, the same variant answered differently is a separate cart item. A required add-on is not checked here: check it before calling, as quick view does.

**It can silently do nothing** — when the bundle-wide maximum is reached, when the step's own limit is reached, or when the step allows only one of each product and that product is already in. Check `canAddToStep(stepId, price)` first if you need to know.

### `selectOne(product, variantId, stepId?)`

```ts
selectOne(product: Product, variantId: string, stepId?: string): void
```

**Multi-option bundles.** Makes this the step's one and only pick, replacing whatever was selected before. Use this rather than `addToCart` for options — `addToCart` would leave two items in the same option.

```js
sdk.selectOne(item.product, item.variant.id, option.id);
```

### `removeFromCart(variantId, stepId?)` · `updateQuantity(variantId, delta, stepId?, itemId?)`

Remove a line, or change its quantity by `delta`. A quantity of zero or less removes the line. `updateQuantity` is subject to the same limits as `addToCart` and will no-op rather than exceed them. One variant can be several items (each with its own add-on answers): pass the item's `id` as `itemId` to change that one; without it, the variant's latest item changes.

### `getItemQuantity(variantId, stepId?)`

**`number`** — how many of this variant are in the cart.

### `isStepFull(stepId)` · `canAddToStep(stepId, price?)`

**`boolean`** — whether the step has hit its own maximum, and whether one more item may be added. `canAddToStep` also accounts for the bundle-wide maximum, so it can be `false` even for a step with no limit of its own. Pass the variant's price for amount limits: a $30 item may not fit where a $10 one does. On a free gift step only the step's own limit applies, once the bundle has the paid items the gift needs.

### `setDetails(details)`

```ts
setDetails(details: Record<string, string>): void
```

Replaces what's filled in for `bundleAddOns`. It goes to the cart with the bundle, on its line, and is cleared once it's added.

### `checkout(redirectTarget?)`

```ts
checkout(redirectTarget?: "stay_on_page" | "cart" | "checkout"): Promise<void>
```

Adds the whole bundle to the Shopify cart. Only call it when `canCheckout` and `detailsComplete` are true: with a required bundle add-on empty it sets `checkoutError` and adds nothing.

| `redirectTarget`           | Behaviour                                            |
| -------------------------- | ---------------------------------------------------- |
| `"stay_on_page"` (default) | Stays put and asks the theme to open its cart drawer |
| `"cart"`                   | Redirects to `/cart`                                 |
| `"checkout"`               | Redirects to Shopify checkout                        |

**On success the bundle is emptied** and a new bundle session begins, so a `stay_on_page` UI must be ready to re-render from an empty cart.

**On a subscription**, `checkout()` adds every item as its own cart line with its `selling_plan` and the bundle's `_bundle_id` property, because Shopify does not merge lines that carry a selling plan. A one-time purchase is still one bundle line. The SDK has no method to pick a plan: `checkout()` uses the plan chosen in the built-in picker, or else the bundle's default from **Config → Subscriptions**.

***

## Translated text

`title` and `description` are `TranslatableString` — either a plain string, or `{ default, translations }` when the merchant has translated them. Rendering one directly can print `[object Object]`:

```js
const text = (value, locale) =>
  typeof value === "string"
    ? value
    : value?.translations?.[locale] ?? value?.default ?? "";

text(step.title, document.documentElement.lang);
```

***

## Full example

```js
document.addEventListener("elite:byob:ready", () => {
  const sdk = window.eliteBundle.sdk?.byob;
  if (!sdk) return; // SDK access is not enabled

  sdk.steps.forEach((step) => {
    const isOption = step.optionMode !== undefined;

    step.products.forEach((product) => {
      const variant = product.variants.find((v) => v.available);
      if (!variant) return;

      const card = renderCard(product, variant, sdk.formatPrice(Number(variant.price)));
      card.addEventListener("click", () => {
        // One pick per option; many picks per step.
        if (isOption) sdk.selectOne(product, variant.id, step.id);
        else sdk.addToCart(product, variant.id, step.id);
      });
      document.querySelector(`#step-${step.id}`).appendChild(card);
    });
  });
});

document.addEventListener("elite:byob:cart-change", (event) => {
  const { cartTotal, cartCount, canCheckout } = event.detail;
  const sdk = window.eliteBundle.sdk?.byob;

  document.querySelector("#total").textContent = sdk.formatPrice(cartTotal);
  document.querySelector("#count").textContent = cartCount;
  document.querySelector("#add-btn").disabled = !canCheckout;
});
```

See the [Events Reference](/reference/events.md) for every event and its payload.
