Skip to content
HeapByte — Senior Shopify Engineering Studio

Engineering · 23 October 2026

Shopify Cart Transform API: bundle expansion, component pricing, and inventory integrity

An off-road and overland performance vehicle outfitter prepares to launch its flagship stage-3 suspension package ahead of the autumn trail season. On launch day, an enthusiastic truck owner selects the 'Heavy-Duty Stage 3 Overland Suspension System' ($2,499.99) and proceeds to checkout. In a legacy bundle app setup, client-side theme JavaScript would push seven separate lines into the cart via AJAX: two front coilovers, two remote-reservoir rear shocks, tubular upper control arms, and grade-8 mounting hardware. Thirty seconds later, the buyer realises they already upgraded their control arms last season, deletes that single child line in the cart drawer, applies a site-wide promotional coupon, and checks out with an incomplete build while bagging component prices below wholesale cost. Meanwhile, at the distribution centre, pickers discover the remote-reservoir shocks went out of stock ten minutes earlier, leaving the warehouse with a stranded, half-shipped order and an angry customer.

5 min read
Written by the HeapByte engineering team

Shopify cart transform bundle

The legacy bundle trap: client-side cart pollution and orphaned components

For years, Shopify merchants built kits and product bundles using frontend JavaScript widgets. When a customer clicked 'Add Bundle to Cart', theme scripts called /cart/add.js with an array of individual variant IDs, sometimes attaching a shared custom attribute like _bundle_id: 'xyz'.

This client-side pattern suffers from four fundamental architectural flaws: orphaned line items where customers delete compulsory kit accessories; discount collisions where automated promotional codes compound across child lines; shipping weight miscalculations; and checkout injection vulnerabilities where technical shoppers manipulate network calls to purchase discounted individual components.

To eliminate these vulnerabilities, Shopify introduced the Cart Transform API within Checkout Extensibility. Instead of polluting the cart with loose child items, the customer adds a single bundle variant to their cart. The server-side Cart Transform function then expands that variant into its constituent components during checkout evaluation.

The WebAssembly contract: purchase.cart-transform.run and the expand operation

Shopify Cart Transform Functions execute as compiled WebAssembly binaries directly inside Shopify's core checkout pipeline. The function subscribes to the purchase.cart-transform.run target and receives the complete cart payload as JSON input.

When evaluating a bundle, the function emits an expand operation that defines parent-child variant linkages, allocates component fixed prices, and instructs the platform to treat the items as an atomic group.

By expanding lines on the server, customers cannot delete individual components, physical inventory decrements accurately across warehouse bins upon order completion, and downstream ERP and WMS integrations receive explicit component SKUs rather than unmapped virtual kit identifiers.

Penny-rounding arithmetic: proportional price allocation without cent discrepancies

When expanding a parent bundle into components, the sum of component prices must equal the parent bundle's advertised price down to the single cent.

Consider an overland recovery kit sold for $100.00, comprising three equally weighted items. A naive division assigns each item $33.3333... Truncated to currency decimals, $33.33 multiplied by 3 produces $99.99, leaving a 1-cent discrepancy.

In Checkout Extensibility, Shopify's pricing validator enforces strict cent-level reconciliation. If the sum of component fixed prices does not match the parent bundle price exactly, the checkout engine rejects the expansion payload, discarding the bundle transformation and falling back to the parent variant or halting checkout with an unhandled exception.

A resilient expansion engine calculates integer-cent floor allocations based on configured component weight ratios, tracks the cumulative remainder, and assigns the residual pennies to the primary (highest-weighted) component.

ts
import assert from "node:assert/strict";

export interface ComponentDefinition {
  variantId: string;
  sku: string;
  title: string;
  quantityPerBundle: number;
  weightRatio: number; // For proportional pricing distribution
  inStockQuantity: number;
}

export interface BundleDefinition {
  bundleVariantId: string;
  bundleSku: string;
  bundleTitle: string;
  advertisedPriceCents: number; // In smallest currency subunit (cents)
  components: ComponentDefinition[];
}

export interface CartLineItemInput {
  id: string; // gid://shopify/CartLine/101
  merchandiseId: string; // gid://shopify/ProductVariant/9001
  quantity: number;
  costCents: number;
  attributes: Record<string, string>;
}

export interface CartTransformRunInput {
  cartLines: CartLineItemInput[];
  bundlesCatalog: Record<string, BundleDefinition>;
  maxCartLineCeiling?: number;
}

export interface ExpandOperationLineItem {
  merchandiseId: string;
  quantity: number;
  price: {
    adjustment: {
      fixedPricePerUnit: {
        amount: string;
        currencyCode: string;
      };
    };
  };
  attributes?: Array<{ key: string; value: string }>;
}

export interface ExpandOperation {
  cartLineId: string;
  title: string;
  image?: { url: string };
  lineItems: ExpandOperationLineItem[];
}

export interface CartTransformRunResult {
  status: "EXPANDED" | "NOOP" | "INVENTORY_ERROR" | "CART_OVERFLOW_ERROR";
  operations: Array<{ expand: ExpandOperation }>;
  totalExpandedLines: number;
  allocatedPriceCents: number;
  discrepancyCents: number;
  errorMessage?: string;
}

/**
 * Pure WebAssembly Cart Transform simulation engine for bundle expansion.
 */
export function expandBundleCartTransform(
  input: CartTransformRunInput,
  currencyCode: string = "USD"
): CartTransformRunResult {
  const lineCeiling = input.maxCartLineCeiling ?? 250;
  const operations: Array<{ expand: ExpandOperation }> = [];
  let totalProjectedLines = input.cartLines.length;
  let allocatedPriceCentsSum = 0;
  let discrepancyCentsSum = 0;

  for (const line of input.cartLines) {
    const bundleDef = input.bundlesCatalog[line.merchandiseId];
    if (!bundleDef) {
      continue;
    }

    // Check inventory sufficiency across all constituent components
    for (const comp of bundleDef.components) {
      const requiredQuantity = comp.quantityPerBundle * line.quantity;
      if (comp.inStockQuantity < requiredQuantity) {
        return {
          status: "INVENTORY_ERROR",
          operations: [],
          totalExpandedLines: totalProjectedLines,
          allocatedPriceCents: 0,
          discrepancyCents: 0,
          errorMessage: `Component SKU ${comp.sku} is out of stock (required: ${requiredQuantity}, available: ${comp.inStockQuantity}) for bundle ${bundleDef.bundleSku}.`,
        };
      }
    }

    // Check cart line limit expansion defense (Shopify 250 line limit)
    const netLineAddition = bundleDef.components.length - 1;
    if (totalProjectedLines + netLineAddition > lineCeiling) {
      return {
        status: "CART_OVERFLOW_ERROR",
        operations: [],
        totalExpandedLines: totalProjectedLines + netLineAddition,
        allocatedPriceCents: 0,
        discrepancyCents: 0,
        errorMessage: `Bundle expansion exceeds Shopify checkout limit of ${lineCeiling} lines.`,
      };
    }

    // Calculate price allocation across components with exact cent-parity
    const totalWeight = bundleDef.components.reduce((sum, c) => sum + c.weightRatio, 0);
    const bundleUnitCents = bundleDef.advertisedPriceCents;
    let distributedCents = 0;

    // First pass: calculate proportional floor cents
    const componentPrices: number[] = bundleDef.components.map((comp) => {
      const share = comp.weightRatio / totalWeight;
      const compTotalCents = Math.floor(bundleUnitCents * share);
      distributedCents += compTotalCents;
      return compTotalCents;
    });

    // Remainder penny distribution: assign residual cents to highest weight component
    let remainderCents = bundleUnitCents - distributedCents;
    if (remainderCents > 0) {
      let maxWeightIndex = 0;
      let maxWeight = -1;
      for (let i = 0; i < bundleDef.components.length; i++) {
        if (bundleDef.components[i].weightRatio > maxWeight) {
          maxWeight = bundleDef.components[i].weightRatio;
          maxWeightIndex = i;
        }
      }
      componentPrices[maxWeightIndex] += remainderCents;
      distributedCents += remainderCents;
    }

    // Verify cent parity
    const parityError = bundleUnitCents - distributedCents;
    assert.equal(parityError, 0, "Price allocation must match bundle unit price down to zero cents");

    allocatedPriceCentsSum += distributedCents * line.quantity;
    discrepancyCentsSum += parityError;

    // Build the expand operation line items
    const expandedLineItems: ExpandOperationLineItem[] = bundleDef.components.map((comp, idx) => {
      const compCents = componentPrices[idx];
      const formattedPrice = (compCents / (100 * comp.quantityPerBundle)).toFixed(2);

      return {
        merchandiseId: comp.variantId,
        quantity: comp.quantityPerBundle * line.quantity,
        price: {
          adjustment: {
            fixedPricePerUnit: {
              amount: formattedPrice,
              currencyCode,
            },
          },
        },
        attributes: [
          { key: "_bundleParentId", value: line.merchandiseId },
          { key: "_bundleSku", value: bundleDef.bundleSku },
          { key: "_componentSku", value: comp.sku },
        ],
      };
    });

    operations.push({
      expand: {
        cartLineId: line.id,
        title: bundleDef.bundleTitle,
        lineItems: expandedLineItems,
      },
    });

    totalProjectedLines += netLineAddition;
  }

  if (operations.length === 0) {
    return {
      status: "NOOP",
      operations: [],
      totalExpandedLines: totalProjectedLines,
      allocatedPriceCents: 0,
      discrepancyCents: 0,
    };
  }

  return {
    status: "EXPANDED",
    operations,
    totalExpandedLines: totalProjectedLines,
    allocatedPriceCents: allocatedPriceCentsSum,
    discrepancyCents: discrepancyCentsSum,
  };
}
Why this function distributes rounding remainders to the primary component rather than dividing prices naively: in WebAssembly Cart Transform execution, Shopify validates that the cumulative sum of component unit prices exactly equals the parent bundle variant price down to the smallest currency subunit. A simple floating-point division of a $99.95 bundle across three components produces $33.31666..., which truncated to cents ($33.31 × 3 = $99.93) leaves a 2-cent discrepancy that causes the checkout engine to reject the expansion payload or trigger checkout pricing validation errors. Our engine computes integer-cent allocations, calculates the exact cent remainder, and assigns the residual pennies to the highest-weight component, guaranteeing zero price drift and flawless discount inheritance.

Inventory gating and the phantom oversell vulnerability

A critical design hazard in custom Cart Transform implementations is the phantom bundle oversell. While Cart Transform functions execute during cart and checkout evaluation, they cannot modify the inventory records stored in Shopify's database.

If the function quietly skips the expand operation when a component is out of stock, the checkout engine allows the parent bundle variant to be purchased as an unexpanded line. The customer is charged, but warehouse pickers receive an unexpanded kit SKU without inventory decrementing on the remaining physical components.

To prevent phantom oversells, background synchronization engines must dynamically sync the parent bundle variant's inventory level to Math.min(...componentHeadrooms), and pair Cart Transform with Checkout Validation Functions (cart.validations.generate.run) to halt checkout with clear user-facing guidance whenever any constituent component is depleted.

Platform boundaries: instruction limits, cart line ceilings, and selling plans

Cart Transform functions run within a constrained WebAssembly runtime with a strict limit of 11,000,000 instructions and 128 KB of memory. Complex recursive loops, large JSON parsing, or heavy regex operations will exhaust the instruction budget, causing Shopify to abort the function (<5ms runtime limit).

Shopify checkouts enforce an immutable limit of 250 total line items. If a commercial B2B buyer places twenty bundles containing twelve hardware components each, the expansion engine would attempt to generate 240 additional lines, risking a hard checkout crash. Always calculate projected line counts and guard against the 250-line ceiling.

As of API 2026-10, Shopify Cart Transform expand operations cannot be applied to cart lines that carry a sellingPlanId (subscription products). Attempting to expand a recurring subscription bundle triggers an explicit platform rejection.

When this needs an engineer

You do not need a custom Cart Transform engine if you sell simple two-piece apparel sets or curated gift boxes that can be managed using Shopify's free native Bundles app.

You need an engineer when your bundles represent technical assemblies: when physical components must be dynamically picked from live warehouse inventory; when custom kits contain variable sub-assemblies; when price allocation must strictly respect dealer margins; and when high-velocity checkout spikes require deterministic WebAssembly execution without third-party app iframe delays.

Review our work in custom application engineering, explore the Forged 4x4 Shopify Plus case study, or inspect our Shopify Plus development services.

Send us your bundle configuration specifications and component inventory structure. We will audit your purchasing flow, architect a pure WebAssembly Cart Transform function, and ensure your checkout delivers flawless pricing and inventory accuracy under peak traffic.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.