@kitenzo/core
v0.5.0
Published
Framework-agnostic SDK for [Kitenzo](https://apps.shopify.com/bundlebuilder). Provides an API client, bundle configuration state machine, types, and utilities for building custom bundle experiences on any JavaScript platform.
Readme
@kitenzo/core
Framework-agnostic SDK for Kitenzo. Provides an API client, bundle configuration state machine, types, and utilities for building custom bundle experiences on any JavaScript platform.
Use this package directly for vanilla JS, Vue, Svelte, or server-side integrations. For React, use @kitenzo/react which wraps this package with hooks and components.
Install
npm install @kitenzo/coreQuick Start
import { KitenzoClient, createBundleBuilder, addBundleToCart } from '@kitenzo/core';
// 1. Create a client
const client = new KitenzoClient({
apiKey: 'kit_live_...',
});
// 2. Find your bundle
const bundles = await client.listBundles();
const bundleId = bundles[0].id;
// 3. Fetch bundle with full product data
const bundle = await client.getBundle(bundleId);
// 4. Build a bundle configuration
const builder = createBundleBuilder(bundle);
builder.subscribe(() => {
const state = builder.getState();
console.log('Selections:', state.selections);
console.log('Section:', state.currentSectionIndex);
console.log('Valid:', state.isComplete);
console.log('Errors:', state.errors);
});
const section = bundle.sections[0];
builder.addItem(section.id, section.products[0].variants[0].id);
// 5. Submit and add to cart
const selections = builder.getState().selections;
const result = await client.submitBundle(bundle, selections);
await addBundleToCart(result, {
addLines: (lines) => cart.linesAdd(lines),
getAttributes: () => cart.attributes ?? [],
setAttributes: (attrs) => cart.cartAttributesUpdate(attrs),
}, { bundle, selections });Full Bundle Embed
Use createBundleEmbed to render the full admin-configured bundle experience — the same UI merchants see on their storefront — inside any container element. No custom UI code needed.
import { createBundleEmbed } from '@kitenzo/core';
const embed = createBundleEmbed(document.getElementById('bundle-root')!, {
bundleId: 42,
apiKey: 'kit_live_...',
shopDomain: 'my-store.myshopify.com',
onAddToCart: ({ lines, bundleContent }) => {
// lines are Storefront API CartLineInput objects
// Add them to your cart via cartLinesAdd
cart.linesAdd(lines);
// For native bundles, set the _bundles cart attribute so the
// cart transform extension can apply the discount at checkout.
if (bundleContent) {
const existingBundles = JSON.parse(
cart.attributes?.find(a => a.key === '_bundles')?.value ?? '{}'
);
existingBundles[bundleContent.configuredBundleId] = bundleContent;
cart.cartAttributesUpdate([
...(cart.attributes ?? []).filter(a => a.key !== '_bundles'),
{ key: '_bundles', value: JSON.stringify(existingBundles) },
]);
}
},
onError: (error) => console.error('Embed error:', error),
});
// Clean up when done:
embed.destroy();The embed automatically loads the storefront script, fetches shop settings (currency, moneyFormat), and intercepts add-to-cart events — normalizing the payload to Storefront API format.
onAddToCart Payload
The onAddToCart callback receives an EmbedCartPayload with:
| Field | Type | Description |
|-------|------|-------------|
| lines | CartLine[] | Cart lines ready for cartLinesAdd (Storefront API format). |
| bundleContent | BundleContentPayload \| undefined | Bundle metadata for native bundles. When present, write it to the _bundles cart attribute so the cart transform can apply discounts. |
BundleContentPayload contains:
| Field | Type | Description |
|-------|------|-------------|
| id | number | Bundle ID. |
| configuredBundleId | number | Unique ID for this bundle configuration. Use as key in the _bundles attribute. |
| title | string | Bundle title. |
| discount | string | Encrypted discount string (consumed by the cart transform). |
| image | string \| null | Bundle image URL. |
| items | Array<{ variantId, count }> | Products in the bundle. |
Each cart line in lines includes a _bundle_data attribute containing configuredBundleId#parentVariantId#uniqueId. This is how the cart transform matches individual line items to their bundle. When using the embed, these attributes are set automatically. If you construct cart lines manually (e.g. re-adding items from a saved cart), every line item that belongs to a native bundle must have the _bundle_data attribute — without it, the cart transform cannot identify the line as part of a bundle.
Important: Both
_bundle_data(on each line item) and_bundles(on the cart) are required for the cart transform to apply discounts. The embed does not write the_bundlescart attribute itself — youronAddToCarthandler must do it.
Bundle Type Differences
All necessary line item attributes (_bundle_data, _bundle_id, _bundle_price, subscription fields, etc.) are included automatically in lines — pass them through to cartLinesAdd as-is.
Native bundles: bundleContent is present. You must write it to the _bundles cart attribute (see example above) — the cart transform reads it to apply the discount at checkout.
Single-product and multiple-products bundles: bundleContent is undefined. No cart attributes needed — the discount is already baked into the variant price. Just call cartLinesAdd(lines).
Note: Only one embed per page is supported.
Setup
1. Enable the Headless API
In the Kitenzo admin panel, go to Settings > Headless to enable the headless API.
2. Create an API Key
On the same page, click Create API key. Keys are prefixed kit_live_ (production) or kit_test_ (development).
API key security:
kit_live_andkit_test_keys are publishable keys, safe to use in client-side code (similar to Stripe publishable keys). They can only read bundle data and submit configurations — they cannot modify bundles or access admin functionality. Do not confuse these with server-side secrets.
3. Configure Allowed Origins (CORS)
If you're calling the API from a browser, you must add your domain to the API key's allowed origins list. Go to Settings > Headless, click your API key, and add each origin (e.g. https://your-store.com). Requests from unlisted origins will be blocked by CORS.
API Client
const client = new KitenzoClient({
apiKey: 'kit_live_...', // Required — your publishable API key
apiVersion: 'v1', // Optional (default: 'v1')
baseUrl: 'https://...', // Optional — override the API base URL
countryCode: 'GB', // Optional — Shopify Markets country (see below)
});When no baseUrl is provided, the SDK uses https://live.bb.eight-cdn.com/api/headless/{apiVersion}. Use baseUrl for local development (e.g. /api/headless/v1 behind a Vite proxy) or custom proxy setups — when provided, apiVersion is ignored.
Methods
| Method | Returns | Description |
|--------|---------|-------------|
| listBundles() | Bundle[] | Published bundles (lightweight, no product data). |
| getBundle(id, options?) | BundleDetail | Bundle with sections, products, and variants — everything needed to render a builder. Accepts an optional { countryCode } for market pricing. |
| getPrice(bundle, selections, options?) | PriceResponse | Server-authoritative pricing for a selection without creating a configuration (read-only counterpart to submitBundle). Returns base-currency amounts; accepts { countryCode }. |
| submitBundle(bundle, selections, options?) | SubmitBundleResult | Submit builder selections — handles product mapping automatically. Accepts an optional { countryCode } for market-aware pricing. |
| getSettings() | ShopSettings | Shop settings (currency, moneyFormat, features). Auto-fetched by KitenzoProvider in React. |
Variant IDs accept GID format (gid://shopify/ProductVariant/123) or plain numeric strings ("123"). When a countryCode is set on the constructor it is the default for getBundle, getPrice and submitBundle; pass one in the per-call options to override it.
API Endpoints
All endpoints require a Bearer token (Authorization: Bearer kit_live_...).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/headless/v1/bundles | List published bundles |
| GET | /api/headless/v1/bundles/:id | Bundle detail |
| GET | /api/headless/v1/bundles/:id/products | Product and variant data |
| POST | /api/headless/v1/bundles/:id/configure | Validate and create configuration |
| POST | /api/headless/v1/bundles/:id/price | Calculate price |
| GET | /api/headless/v1/settings | Shop settings |
Rate limit: 100 requests/minute per API key (configurable).
Kitenzo State Machine
createBundleBuilder(bundle) returns a state machine for step-by-step bundle configuration. It follows the subscribe/getState pattern, making it compatible with React's useSyncExternalStore, Svelte stores, or plain callbacks.
import { createBundleBuilder } from '@kitenzo/core';
const builder = createBundleBuilder(bundleDetail);
// Subscribe to state changes
const unsubscribe = builder.subscribe(() => {
const state = builder.getState();
renderUI(state);
});
// Mutations
builder.addItem(sectionId, variantId, quantity?);
builder.removeItem(sectionId, variantId);
builder.updateQuantity(sectionId, variantId, newQuantity);
builder.reset();
// Navigation
builder.nextSection();
builder.prevSection();
builder.goToSection(index);
// Queries
builder.getSectionQuantity(sectionId); // numberSnapshot Shape
builder.getState() returns a BundleBuilderSnapshot:
{
selections: SectionSelections; // Record<sectionId, BundleSelection[]>
currentSectionIndex: number;
currentSection: BundleSection | null;
isSectionValid: boolean;
isValid: boolean;
isComplete: boolean; // all sections valid + all rules pass (see caveat below)
isSatisfied: boolean; // the bundle's real limit rules are met — gate add-to-cart on this
// Sections also carry `autoNextSection`: the merchant's "advance once this step is
// satisfied" setting. Mirror it rather than hardcoding the behaviour.
allItems: BundleSelection[]; // flat list of all selections
errors: ValidationError[]; // limit rule / required product violations (empty when valid)
conditions: ConditionsSnapshot; // conditional discount + hidden sections/products (see Conditions Engine)
}The snapshot is immutable — a new object reference is created on each state change (for efficient React reconciliation or shallow comparison).
isComplete vs isSatisfied. isComplete treats a section with no stated minimum as
needing at least one pick. That is wrong for the two commonest bundle shapes — a
bundle-wide "pick any 4 across groups", and a step whose only rule is a maximum — so it
reports false forever on bundles the API will happily accept. isSatisfied reads the
bundle's actual limit rules instead. isComplete is unchanged, because existing
integrations depend on it; gate new add-to-cart buttons on isSatisfied.
Limit Rules (pick counts)
The bundle structure carries the layout and the bundle carries the pick counts. There is
no min/max field on BundleSection — a step's counts arrive as
total-number-of-products limit rules tagged with that step's sectionId, and
"pick any N across groups" arrives as the same rule with sectionId: null.
getSectionLimits(bundle, sectionId) → PickLimits
| Rules on the step | Result |
| --- | --- |
| eq 8 | { min: 8, max: 8 } — exactly 8 |
| gte 3 + lte 5 | { min: 3, max: 5 } — a range |
| gte 4 | { min: 4, max: UNBOUNDED } — at least 4, no ceiling |
| lte 5 | { min: 0, max: 5 } — up to 5, nothing required |
| none | { min: 0, max: UNBOUNDED } — optional, free quantity |
gt and lt are exclusive and convert to the reachable whole number: gt N is a minimum
of N + 1, lt N a maximum of N - 1. Translate all five, or the widget under-gates a
step the engine then rejects at /configure.
The bundle-wide rule is deliberately not consulted here: "pick any 4 across groups" says nothing about how many come from each group.
Do not send a PickLimits through JSON. An open ceiling is UNBOUNDED
(Infinity), and JSON.stringify turns that into null — after which count <= max is
false for every count, so every step reads as full. Recompute limits on the side that
uses them (they are derived from the bundle, which you already have), or map max to
your own sentinel before serialising.
getBundleLimits(bundle)
The bundle-wide window, from the count rule with sectionId: null — the
"pick any N across groups" shape. min is what a "pick 4" tracker needs to size its
slots; max is what tells it when the bundle is full.
const { min, max } = getBundleLimits(bundle);
const slots = Array.from({ length: min }, (_, i) => picks[i] ?? null);
const canAdd = picks.length < max;snapshot.isSatisfied
Whether the selection actually satisfies the bundle, computed from these windows.
Read it off builder.getState() (or useBundleBuilder) — that is the only place it is
exposed, because the builder is how every consumer holds selections, the vanilla path
included.
It judges the effective configure payload, not just what the shopper touched: a
required product usually belongs to the bundle rather than to a step, so it never appears
in selections and the SDK appends it on submit. Counting only selections would report
every such bundle unsatisfiable forever. Appended required products count toward the
bundle-wide total (as the engine counts them) but not toward any per-section window (as the
engine does not).
An empty selection is never satisfied. Rules targeting a conditions-engine-hidden section
are still checked, because the engine's own validator has no conditions awareness and
would reject the configuration; hiding only relieves a section of its own minQuantity.
Client-side rule validation mirrors the engine's validate_bundle_limits, which is what
accepts or rejects the configuration at /configure. Where the two disagreed the shopper
got a generic server rejection instead of an explanation, so two semantics are matched
deliberately and are worth knowing when reading errors:
amount-of-one-product/amount-of-one-varianthold when every product satisfies the rule, not just the most-picked one, and hold vacuously on an empty selection. (For the commonlte"at most N of any one product" shape these are the same thing; they diverge forgte/gt/eq.)multiples-ofignores its comparison operator: the value is the unit and the count must be a positive multiple of it. A zero or blank unit is a no-op.
Product Options (one dropdown per option)
A custom UI that renders Hand / Loft / Shaft rather than a flat list of variant titles has to answer three questions on every click: which variant this selection means, which values are still reachable, and what happens to the other options when one changes. These four functions answer them, so no widget has to re-derive the rules.
import { defaultOptionValues, reachableOptionValues, resolveVariant, selectOptionValue } from '@kitenzo/core';
// OptionSelection is Record<optionName, chosenValue>
const [values, setValues] = useState<OptionSelection>(() => defaultOptionValues(product));
const variant = resolveVariant(product, values); // BundleVariant | null
// Render one <select> per option, disabling what is out of reach
const offered = reachableOptionValues(product, values, 'Hand');
// Apply a change; later options repair themselves
setValues(selectOptionValue(product, values, 'Hand', 'Left Hand'));Option order is significance order. A value is offered or withheld based only on the options before it, and changing one repairs only the options after it. That rule is not cosmetic: on a driver that is left-handed in one loft only, constraining on every other option means a shopper who picked the 9° can never switch to left-handed — the value sits disabled with nothing telling them which other option to change first. Repairing in both directions is worse still: a shaft change silently flips the hand they deliberately chose.
Disable unreachable values rather than hiding them, so a shopper can see that left-handed exists before working out how to reach it. Values come back in the merchant's declared order, and only available variants count.
These need an API serving product options and per-variant optionValues. Without them
there is no grid to resolve and every function returns empty.
Personalisation
When the shop's personalisation_sets feature is on, BundleDetail.personalisation
carries the per-product field definitions — engraving text, a gift message, an uploaded
image — keyed by Shopify product id as a string. The key is omitted entirely when the
feature is off, and it covers section products and required products alike.
const fields = bundle.personalisation?.['7654321'] ?? [];In custom-UI mode you own the rendering. Submit the shopper's answer as a line-item
property under the field's key, not its label — key is frozen at creation and
does not change when the merchant renames the label. For an image field, host the image
yourself and submit its URL as the value.
A field may carry a fee (PersonalisationFieldFee): an upcharge added to the cart as
its own line for the hidden fee product at fee.variantId, priced fee.amount in the
shop's base currency.
Pricing
calculatePrice(bundle, selections, currency?)
Calculate bundle pricing from variant prices and discount rules. Handles flat discounts (percentage, fixed, price) and tiered discounts with all operation types.
The currency parameter is a label included in the response — pass the shop's currency from getSettings() so the returned PriceResponse.currency is accurate. If omitted, the field is empty.
import { calculatePrice } from '@kitenzo/core';
const settings = await client.getSettings();
const pricing = calculatePrice(bundle, builder.getState().selections, settings.currency);
console.log(pricing.originalPrice); // "45.00"
console.log(pricing.discountedPrice); // "40.50"
console.log(pricing.discountType); // "percentage"
console.log(pricing.discountValue); // "10.00"
console.log(pricing.currency); // "CZK"React users: Use the
useBundlePricehook instead — it returns pre-formatted prices (e.g."$45.00") using the shop's moneyFormat automatically.
calculatePriceWithConditions(bundle, selections, options?)
Like calculatePrice, but also applies the conditions engine: if a conditional discount (apply-discount) fires for the current selection, it replaces the flat/tiered discount. Use this for the displayed price on conditions-driven bundles. options accepts { currency, currentStepName }. React's useBundlePrice uses it automatically. See Conditions Engine.
resolveUpfrontFixedPrice(bundle)
The price the bundle will sell at before anything is selected, when that is knowable. A flat price-type discount fixes the final price regardless of what the shopper picks, so it can be shown immediately; every other configuration (percentage, fixed, and tiered discounts) depends on the selection and returns null.
import { resolveUpfrontFixedPrice } from '@kitenzo/core';
resolveUpfrontFixedPrice(bundle); // "65.00" for a flat price discount, else nullReturned in the shop's base currency — convert it with resolveMarketPricingContext for a market-loaded bundle.
React users:
useBundlePricecalls this for you — with no selections it returns the fixed price instead of a blank one.
formatMoney(amount, moneyFormat)
Format an amount using a Shopify money format string. Supports all standard Shopify placeholders.
import { formatMoney } from '@kitenzo/core';
formatMoney(45, '${{amount}}'); // "$45.00"
formatMoney(1234.5, '€{{amount_with_comma_separator}}'); // "€1.234,50"
formatMoney('99.99', '{{amount}} kr'); // "99.99 kr"Shopify Markets (localised pricing)
By default prices are returned in the shop's base currency. To show a shopper the
price they'll pay in their Shopify Market, pass a countryCode (ISO 3166-1
alpha-2). Shopify derives the market from the country, so a country is all you need.
const client = new KitenzoClient({ apiKey: 'kit_live_...', countryCode: 'DE' });
const bundle = await client.getBundle(42); // variants now carry presentment pricesWhen a countryCode is supplied, each variant gains these fields (all optional —
absent when no market was requested, or when the shop has no Shopify Markets):
| Field | Description |
|-------|-------------|
| presentmentPrice | The price in the shopper's market currency (full precision). |
| presentmentCurrency | ISO 4217 code for that price (e.g. "EUR"). |
| priceInShopCurrency | The presentment price expressed back in shop currency. |
| availableForSale | Whether the variant is sellable in that market. false = not sold there (or out of stock there) — distinct from available, which is base-store stock only. Gate selection on this in a market. |
The base price (shop currency) is always present, so you can render a compare-at
or fall back. Omit countryCode and the response is byte-identical to before.
Formatting a localised price
The shop's moneyFormat only fits the base currency. Format presentment amounts
with formatCurrency, and turn a base-currency PriceResponse into the market
currency with resolvePresentmentPricing:
import { calculatePrice, resolvePresentmentPricing, formatCurrency } from '@kitenzo/core';
const base = calculatePrice(bundle, selections, settings.currency);
const pricing = resolvePresentmentPricing(bundle, selections, base) ?? base; // null when no market
formatCurrency(pricing.discountedPrice, pricing.currency); // "€21.60"resolvePresentmentPricing shows the exact market subtotal (Σ presentment price)
and applies the bundle discount as the same ratio the base price used — the same
way the Kitenzo theme widget renders, and the way the Cart Transform reconciles at
checkout. This is exact for percentage discounts in any market, and for every
discount type under Shopify's standard exchange-rate conversion.
React users: the
useBundlePricehook does all of this automatically once you setcountryCodeon<KitenzoProvider>— see@kitenzo/react.
resolveMarketPricingContext(bundle)
resolvePresentmentPricing needs a selection to convert. When you need the market
currency and rate without one — to show a fixed bundle price before the first
pick, say — derive them from the bundle itself:
import { resolveMarketPricingContext, resolveUpfrontFixedPrice, formatCurrency } from '@kitenzo/core';
const market = resolveMarketPricingContext(bundle); // null when not market-loaded
const fixed = resolveUpfrontFixedPrice(bundle); // null unless a flat price discount
if (fixed && market) {
formatCurrency((Number(fixed) * market.rate).toFixed(2), market.currency); // "€60.00"
}The rate comes from the first section variant carrying usable presentment data —
its presentmentPrice / priceInShopCurrency, the same rate the Cart Transform
applies to fixed discounts at checkout. Variants missing either figure, or
priced at zero, are skipped. Under fixed per-market pricing the rate varies per
variant, so this shares the proportional approximation described above.
Match the cart. The displayed price only matches checkout if the same
countryCodeis set as thebuyerIdentity.countryCodeon your Shopify cart (cartBuyerIdentityUpdate).submitBundleforwards yourcountryCodeto the configure call, so the native discount stays consistent.
Not covered: translated product titles (locale) — a separate concern from price. Personalisation fees and subscription selling-plan prices are shown in the shop's base currency. Responses become market-specific, so include the country in any cache key you add in front of the API.
Conditions Engine
Bundles configured with the conditions engine — conditional discounts and/or conditional step & product visibility — are supported client-side. When the shop has the feature enabled, getBundle() includes the serialised rule graph on BundleDetail.conditionsEngineNodes, and createBundleBuilder evaluates it on every state change. Read the result from the snapshot's conditions field:
const { conditions } = builder.getState();
conditions.hiddenSectionIds; // number[] — section ids to hide
conditions.hiddenProducts; // { productId: string; sectionId: number | null }[]
conditions.hideCartButton; // boolean — disable add-to-cart
conditions.gotoSectionId; // number? — a step-goto target
conditions.discountOverride; // { type, value }? — the conditional discountalwaysShow rules are already applied, so hiddenSectionIds / hiddenProducts are the final set to hide.
- Pricing — use
calculatePriceWithConditions(bundle, selections)(or theuseBundlePricehook) to reflect a conditional discount in the displayed price. - Submitting —
submitBundle()computes the conditional discount and sends it automatically; the server validates and signs it into the cart payload. - Completion — sections hidden by the rules don't block
isComplete.
Supported conditions: bundle-price, bundle-product-total, bundle-contains-product(-amount), product-quantity, product-stock, product-title, step-title, step-contains-product(-amount), step-product-total, step-price. Supported actions: apply-discount, product-visibility (hide/show), step-visibility (hide/show), add-to-cart-btn (disable), step-goto.
Limitations
- Cascade / variant-selected rules are not evaluated yet. They are stripped from the payload server-side; if a bundle uses them,
BundleDetail.conditionsPartialistrue— the rest of the graph still evaluates, but the bundle is not fully driveable headlessly. Check this flag if full parity matters. - Price-based conditions evaluate on base (shop-currency) prices, not buyer/country (Markets) prices, even when
countryCodeis passed tosubmitBundle. - The conditional discount is computed client-side and signed by the server (the same trust model as the Kitenzo theme storefront). The
configureendpoint remains the authoritative validator.
Cart Utilities
addBundleToCart(result, cart, context?)
Add a configured bundle to a Shopify cart in one call. Builds cart lines, merges the _bundles cart attribute, and calls both mutations — so the cart transform can always apply the discount. Pass { bundle, selections } as the third argument for native bundles, which is required to produce the correct _bundle_data line attributes and _bundles cart attribute.
import { addBundleToCart } from '@kitenzo/core';
const result = await client.submitBundle(bundle, selections);
await addBundleToCart(result, {
addLines: (lines) => cart.linesAdd(lines),
getAttributes: () => cart.attributes ?? [],
setAttributes: (attrs) => cart.cartAttributesUpdate(attrs),
}, { bundle, selections });buildCartPayload(result, existingAttributes?, context?)
Lower-level alternative when you need direct control. Returns { lines, attributes } — you must call both cartLinesAdd and cartAttributesUpdate yourself. Pass { bundle, selections } as the third argument for native bundles.
import { buildCartPayload } from '@kitenzo/core';
const result = await client.submitBundle(bundle, selections);
const { lines, attributes } = buildCartPayload(result, cart.attributes, { bundle, selections });
cart.linesAdd(lines);
cart.cartAttributesUpdate(attributes);buildCartLines(result)
Build cart lines only (without attribute merging). Useful when you manage cart attributes separately.
import { buildCartLines } from '@kitenzo/core';
const result = await client.submitBundle(bundle, selections);
const lines = buildCartLines(result);
cart.linesAdd(lines);Section Utilities
computeSectionQuantity(selections, sectionId)
Returns the total quantity selected in a section.
isSectionMet(selections, section)
Returns whether a section's minimum/maximum quantity constraints are satisfied.
TypeScript Unions
The package exports typed union types for fields that accept a fixed set of values, providing autocomplete and compile-time safety:
| Type | Values |
|------|--------|
| BundleType | 'single-product' | 'multiple-products' | 'native' |
| BundlingOption | 'bundles' |
| DiscountType | 'percentage' | 'fixed' | 'price' |
| DiscountMode | 'flat' | 'tiered' |
| ComparisonOperator | 'gt' | 'gte' | 'lt' | 'lte' | 'eq' |
| TierOperator | 'max' | 'cumulative' |
| TierConditionType | 'total_products' | 'bulk_buy' | 'total_price' |
| LimitRuleType | 'bundle-price' | 'total-number-of-products' | ... |
| PersonalisationFieldType | 'text' | 'dropdown' | 'checkbox' | 'image' |
BundlingOption admits three deprecated members. 'upsells' is a legacy mode that the
API does still emit — a published upsell bundle comes back from GET /bundles like
any other, since neither endpoint filters on this field — but it is not a supported mode
for headless builds; handle it defensively rather than building on it. 'native' and
'custom' were never emitted by any version of the API and are retained only so existing
code keeps compiling. 'subscriptions' is declared in the field's choices but is never
written or read by any code path, so it is not in the union.
Prefer === 'bundles' over an exhaustive switch.
Note that a discount tier's comparison field is operation, while operator on the
parent BundleDiscount is a different thing — how to combine several active tiers. A tier
may also carry customText, the merchant's override for that tier's discount-progress
message (supporting the {{ amount }}, {{ discount }} and {{ currentDiscount }}
placeholders); it is null when they have not set one, in which case your own default
copy applies.
BundleDiscount.type is typed DiscountType | '': the bundle serializer emits
Bundle.discount_type verbatim, and that field is blank when the merchant configured no
discount. PriceResponse.discountType is DiscountType | null — the pricing serializer
normalises the same empty value to null.
Variant Availability
The BundleVariant.available field is a boolean indicating whether the variant can be selected. When available is false, the variant should be shown as disabled. The API does not currently provide a specific reason for unavailability — it may be out of stock, restricted by inventory policy, or excluded by bundle rules. Display unavailable variants as disabled to let customers see the full selection, and use the inventoryQuantity field (when present) to distinguish out-of-stock variants from other restrictions.
When you load a bundle for a Shopify Market (countryCode), each variant also carries availableForSale — whether it is sellable in that market. A variant can be available (in stock in the base store) but availableForSale: false because it isn't part of the market's catalogue. Gate selection / add-to-cart on availableForSale when a market is set, or the shopper can build a bundle that fails at checkout.
Troubleshooting
CORS errors -- Your origin is not in the API key's allowed origins list. Go to Settings > Headless, click your API key, and add your domain.
401/403 -- Verify the API key is correct, active, and headless is enabled.
Cart lines added but no discount -- Make sure cart attributes from buildCartPayload() are applied via cartAttributesUpdate(). If using the embed (createBundleEmbed), check that your onAddToCart handler writes bundleContent to the _bundles cart attribute — see the embed section above.
"Unable to add kit to cart" in console (embed mode) -- This happened in older versions when the embed tried to call Shopify Liquid endpoints (/cart.js, /cart/update.js) that don't exist on headless storefronts. Update to the latest version — the embed now skips these calls when onAddToCart handles the event.
