Developer documentation

Ignis Bundles for theme developers.Contract v1

Last updated 26 August 2026. Applies to the Ignis app for Shopify.

Ignis Bundles separates pricing from presentation. All bundle pricing runs inside a Shopify discount function driven by merchant config. Your theme code renders offers. It can never corrupt pricing. Build any UI you want against four stable surfaces.

That split is the whole point of this page. The function is the only thing that decides what a shopper pays, so a bug in a theme template can produce a wrong-looking number on a product page, but it cannot produce a wrong charge at checkout. Everything below tells you how to read the same inputs the function reads, so your numbers and the checkout total agree.

  1. Surface 1. The config metafield
  2. Surface 2. The cart line protocol
  3. Surface 3. Display math
  4. Surface 4. Appearance
  5. Swatches, four ways
  6. What the server accepts
  7. Guarantees and rules
  8. Blocks versus custom sections
  9. Works well with AI coding tools
  10. Changelog

Surface 1. The config metafield

Bundle settings live in one JSON metafield. It is written automatically every time bundle settings are saved in the Ignis app, so there is nothing to sync by hand and nothing to cache.

PropertyValue
Namespaceignis
Keybundles_config
Typejson
OwnerThe shop
Liquid pathshop.metafields.ignis.bundles_config.value
WrittenOn every save of bundle settings in the Ignis app

It is shop-owned, so it is readable from anywhere in a theme: a section, a snippet, a block you wrote yourself. Liquid parses it for you, so shop.metafields.ignis.bundles_config.value is an object you walk directly, not a string you have to decode.

{%- liquid
  assign cfg = shop.metafields.ignis.bundles_config.value
  assign gid = 'gid://shopify/Product/' | append: product.id
  if cfg.tiers_v2.enabled
    assign tiers = cfg.tiers_v2.default
  endif
-%}

Ignis's own app blocks read the same JSON from a second, app-owned copy at app.metafields.bundles.config. That copy is an internal implementation detail of the app blocks and is not part of this contract. Build against the shop metafield above.

Shape

The full storefront shape, with every key present. Ignis normalizes on write, so all of these keys always exist and none of them are ever null.

{
  "tiers_v2": {
    "enabled": true,
    "display": "dollars",              // "dollars" | "percent"
    "product_mode": "all",             // "all" | "include" | "exclude"
    "product_list": [                  // product GIDs the mode applies to
      "gid://shopify/Product/1234567890"
    ],
    "default": [                        // the ladder, ascending by qty
      { "qty": 2, "discount": { "type": "percent", "value": 10 } },
      { "qty": 3, "discount": { "type": "amount",  "value": 500 } }
    ],
    "overrides": {                      // per-product ladder, keyed by product GID
      "gid://shopify/Product/1234567890": {
        "enabled": true,
        "tiers": [
          { "qty": 2, "discount": { "type": "percent", "value": 15 } }
        ]
      }
    }
  },

  "pairings": {
    "enabled": true,
    "list": [
      {
        "page_product":        "gid://shopify/Product/1234567890",
        "partner_product":     "gid://shopify/Product/9876543210",
        "partner_handle":      "ridge-cap-black",
        "partner_title":       "Ridge Cap, Black",
        "partner_price_cents": 2500,
        "discount": { "type": "amount", "value": 1000 }
      }
    ]
  },

  "bundle_product_ids": [
    "gid://shopify/Product/5555555555"
  ],
  "sale_stacking": true,

  "appearance": {                      // Surface 4; these values are its defaults
    "accent": "#1b1b1b",
    "badge_color": "#1b1b1b",
    "text_color": "#1b1b1b",
    "pill_radius": 6,
    "image_size": 120,
    "tiers_layout": "row",
    "pairing_layout": "stack",
    "block_max_width": "full",
    "block_align": "center",
    "cta_width": "full",
    "cta_radius": 8,
    "cta_label": "",
    "swatch_mode": "auto",
    "swatch_source": "variant_image"
  }
}

One timing caveat. appearance was added to the contract on 26 August 2026. A store whose settings were last saved before that date carries a config without the appearance object (or with only its six original keys) until the next save in the Ignis app. Read appearance values with a per-key default, {{ cfg.appearance.accent | default: '#1b1b1b' }}, and your code is correct in both states.

Fields

FieldMeaning
tiers_v2.enabledMaster switch for quantity tiers. When false, no tier discount is ever granted and tier UI should render nothing.
tiers_v2.displayHow the merchant wants savings labelled: "dollars" or "percent". Presentation only. It never changes what is charged.
tiers_v2.product_mode"all" means every product is tier-eligible. "include" means only products in product_list. "exclude" means everything except products in product_list.
tiers_v2.product_listProduct GIDs the mode applies to. Ignored when the mode is "all".
tiers_v2.defaultThe default ladder. Each entry is a qty threshold and a discount. Sorted ascending by qty.
tiers_v2.overridesPer-product ladders keyed by product GID. An override with "enabled": false turns tiers off for that product. An override with a non-empty tiers array replaces the default ladder for that product.
pairings.enabledMaster switch for pairings.
pairings.list[].page_productThe product whose page the offer belongs on. Pairings are directional: an offer configured for A plus B appears on A's page, not on B's.
pairings.list[].partner_productThe partner product GID.
partner_handle, partner_title, partner_price_centsThe partner resolved at save time, so your template can render the offer without a second lookup. Treat these as a fallback. If the handle still resolves on the storefront, prefer live product data for price and availability.
bundle_product_idsProducts that already carry their own multi-item price, such as a pre-priced pack.
sale_stackingWhether bundle discounts stack on top of a running store sale. See the note under display math.

Semantics you must get right

Surface 2. The cart line protocol

Quantity tiers need no protocol at all. Any N units of the same product in the cart qualify automatically, however they were added, with no line item properties and no cooperation from your code. If you only want a tier ladder, you can skip this section entirely.

Pairings need one thing from you. To sell a pairing, add both products in a single /cart/add.js call, with each line carrying the same line item property _bundle_id. The value can be any fresh unique string.

// A fresh id per click. Never reuse one across separate offers.
function newBundleId() {
  return 'ig-pair-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
}

// Localized storefronts serve the cart under a locale root.
function cartRoot() {
  return (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/';
}

async function addPairing(pageVariantId, partnerVariantId) {
  const bundleId = newBundleId();

  const res = await fetch(cartRoot() + 'cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify({
      items: [
        { id: pageVariantId,    quantity: 1, properties: { _bundle_id: bundleId } },
        { id: partnerVariantId, quantity: 1, properties: { _bundle_id: bundleId } }
      ]
    })
  });

  // 422 is Shopify's "cannot add this line": sold out, or a limit reached.
  if (!res.ok) throw new Error(res.status === 422 ? 'sold-out' : 'add-failed');

  document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
  return res.json();
}

How the engine reads it

Order of allocation. Pre-priced bundle products first, since they are never tier-eligible. Then intact pairings. Then quantity tiers over every unit left. Knowing that order is usually enough to explain any total a shopper sees.

Surface 3. Display math

These are the formulas the pricing function uses. Compute displayed savings this way and your product page will match checkout exactly. All money is in cents, as integers. Round with half away from zero, which is what Math.round does in JavaScript and what divided_by plus integer arithmetic gives you in Liquid.

Quantity tier, per unit

unit  = the variant price in cents
tier  = the highest configured tier whose qty <= the number of units
        of that product in the cart

off_per_unit = tier.discount.type === 'percent'
  ? Math.round(unit * tier.discount.value / 100)
  : tier.discount.value                        // already cents off each unit

off_per_unit = Math.min(off_per_unit, unit)    // clamp at the unit price

group_gross = unit * qty
group_off   = off_per_unit * qty
group_net   = group_gross - group_off

The tier that applies is the highest one the whole pool reaches, and it applies to every unit in the pool. The offer is "buy N, save on all N", not "save on the Nth".

Pairing, split across the two lines

unitA = page product unit price in cents
unitB = partner product unit price in cents
gross = unitA + unitB

// percent: each side is discounted by the same percentage
offA = Math.round(unitA * value / 100)
offB = Math.round(unitB * value / 100)

// amount: the pair total comes off, split in proportion to unit price
offA = Math.round(value * unitA / (unitA + unitB))
offB = value - offA                            // remainder, so nothing is lost to rounding

pair_off = offA + offB
pair_net = gross - pair_off

The proportional split matters. It is why the two cart lines each show part of the saving instead of one line carrying all of it, and it is why offA + offB always equals the configured amount exactly.

Clamps

Apply both clamps in your display code. If you skip them, a misconfigured setting will show a number that checkout will not honor.

Sale stacking. When sale_stacking is true and the store has a sale running on an item, the engine computes a percent discount against the post-sale unit price and then adds the sale amount back, so the shopper lands at sale price minus the bundle discount. With no sale active, that reduces to the plain formulas above, which is what a product page should render in the normal case.

Surface 4. Appearance

The appearance object in the config metafield carries the merchant's presentation choices, set in the Ignis app under Bundles, Edit appearance. It is display only: nothing in it ever reaches the pricing function, so no appearance value can change what a shopper is charged.

The built-in app blocks read every key. A custom section can read all of them, some of them, or none. If your theme has its own design system, it is completely legitimate to ignore this surface and style your bundle UI your own way; the merchant's pricing config still works identically.

The keys

KeyTypeDefaultMeaning
accent#rrggbb#1b1b1bSelection and emphasis color: the selected pill and swatch ring, the add-to-cart face, the tier ladder's best-value marker.
badge_color#rrggbb#1b1b1bThe savings badge and savings amounts.
text_color#rrggbb#1b1b1bBody text on the bundle surfaces.
pill_radiusint, 0 to 246Corner radius in px of the size pills. Colour swatches are always circles and are not affected by this key.
image_sizeint, 48 to 200120Width and minimum height, in px, of each product's photo. Applies in full to the stacked layout, where the photo fills the row's actual height (a taller row from wrapped size pills still shows a full photo instead of a fixed square with dead space beside it). The side-by-side layouts (inline and inline_reverse) use this size too, capped at 96px on desktop and 72px on phones, so the compact card stays compact.
tiers_layoutrow | stackrowTier ladder direction.
pairing_layoutstack | inline | inline_reversestackPairing card: products stacked, side by side, or side by side with the partner product first.
block_max_widthfull | wide | medium | narrowfullMaximum width of the block.
block_alignleft | center | rightcenterWhere a non-full-width block sits.
cta_widthfull | autofullAdd-to-cart button: full width of the card, or shrink to fit its label.
cta_radiusint, 0 to 328Add-to-cart button corner radius in px.
cta_labelstring, up to 60 chars""Custom add-to-cart wording. Empty means the built-in wording.
swatch_modeauto | image | colorautoWhat a colour swatch shows. auto prefers an image and falls back to a colour; image never paints a colour; color never loads an image.
swatch_sourceshopify_swatches | variant_image | filesvariant_imageWhere swatch images come from. The whole story is in Swatches, four ways.

Two rules make old configs and new consumers coexist safely:

One key needs escaping

cta_label is merchant-typed free text rendered to shoppers. The built-in blocks escape it everywhere it lands (Liquid | escape in markup, textContent in JS). If you render it in a custom section, you must do the same. Never interpolate it into innerHTML or an unquoted attribute.

Restyling the built-in blocks

The app blocks publish their appearance as CSS custom properties and data attributes on their root elements, so theme CSS can restyle or extend them without touching their markup. This is also the "bring your own" contract for swatches below.

Pairing block, root .ig-bp:

Attribute / propertyValues
data-wfull | wide | medium | narrow
data-aleft | center | right
data-lstack | inline | inline_reverse
data-smauto | image | color
--ig-bp-accent, --ig-bp-badge, --ig-bp-textcolors
--ig-bp-rpx, the size-pill radius
--ig-bp-imgpx, each product photo's width and minimum height. Applies in full to the stacked layout (the photo stretches to fill the row's actual height); the side-by-side layouts cap it at 96px desktop / 72px mobile and keep the photo square.
--ig-bp-cta-rpx, the button radius
--ig-bp-cta-w100% or auto, the button width

Tier ladder block, root .ig-bt: data-w and data-a as above; data-l is row | stack; --ig-bt-accent and --ig-bt-text.

The DOM order inside the pairing block never changes with layout; inline_reverse is implemented purely in CSS (row-reverse), so JS and analytics hooks can rely on the page's own product always being the first row in the DOM.

Swatches, four ways

Colour swatches are the part of a bundle UI most likely to already exist in your theme, so nothing here is mandatory. Four ways to get swatches working, all equal citizens; pick the one that matches where your images already live.

If the store...PickSetup
Already uses Shopify's built-in swatches (colours or images attached to option values in the admin)shopify_swatchesNone
Has a good product photo per colour variantvariant_imageNone
Wants purpose-made swatch tiles (fabric close-ups, patterns)filesUpload one file per colour value
Has its own swatch system in the themeBring your ownIgnore swatch_source entirely

The sources cascade. swatch_source names where a colour's swatch is looked for first, not the only place. A colour with nothing there falls through to the next source: files tries the uploaded file, then Shopify's swatch, then the variant image; shopify_swatches tries Shopify's swatch, then the variant image; variant_image uses photos only. So a store with Shopify swatch colours on its plain colours and uploaded tiles for its multi-colour values picks files and gets both. swatch_mode is part of the cascade: "Colors only" never shows an image from any rung, and "Images only" never takes a colour swatch, so a plain colour continues to its variant photo.

A value that is itself a readable CSS colour name ("Black", "Navy") always works with zero setup in any mode except image: the block paints the colour directly.

1. Shopify's built-in swatches (shopify_swatches)

Shopify lets a merchant attach a colour or an image to each option value in the admin (the same swatches Shopify's own themes render). If the store already maintains those, this source reads them directly: an attached image wins, an attached colour is next. Nothing to upload, nothing to duplicate, and updating the swatch in the admin updates the bundle UI.

2. Variant images (variant_image)

Each colour value shows the image of the first variant carrying that value. Zero setup, and the swatch always matches a real product photo. The trade-off: a full product shot cropped into a small disc is less readable than a purpose-made tile.

3. Uploaded swatch files (files)

Purpose-made tiles uploaded once to the store's files (admin, Content, Files). The block derives each filename from the option value:

swatch-<handle>.jpg     // tried first
swatch-<handle>.png     // fallback

<handle> is Shopify's handleize of the value: lowercase, and every run of characters that is not a letter or digit becomes a single hyphen. Examples:

Option valueFiles looked for
Blackswatch-black.jpg, then swatch-black.png
Navy Blueswatch-navy-blue.jpg, then swatch-navy-blue.png
Black/Whiteswatch-black-white.jpg, then swatch-black-white.png

The full fallback chain: .jpg, then .png, then a painted colour if the value is a readable colour, then a neutral disc with the value's first letter. A missing file fails silently down that chain. Nothing errors; the swatch just quietly shows a colour dot or a letter instead of your tile. So when setting this up, write out the derived filename for every colour value the store sells and check each one loads: the file's URL is visible in the admin file list, or simply hard-refresh the product page and look at each swatch.

4. Bring your own

If your theme already has a swatch system, keep it. Build your own option pickers against the config and the cart line protocol, and ignore swatch_source completely. The one courtesy worth extending: swatch_mode still expresses the merchant's image-versus-colour preference, so a custom system that honours it keeps the appearance editor truthful.

What the server accepts

Config is validated with a hard whitelist before it is ever written to the metafield, so the shapes in Surface 1 are also the outer limits of what you will ever read. You do not need defensive code for values outside these bounds; they cannot be saved.

RuleBound
Percent discount valueInteger, 1 to 90. Tier rows may also be 0, see below.
Amount discount valueInteger cents, 1 to 100000 ($1,000.00).
Tier qtyInteger, 1 to 100.
Tiers per ladderAt most 10. A duplicate qty is rejected. Stored sorted ascending.
PairingsAt most 200, one per page_product, and the two members must be distinct products.
product_list, bundle_product_ids, overridesAt most 500 entries each.
Product idsFull GIDs matching gid://shopify/Product/<digits>, nothing else.
Unknown keysRejected anywhere in the config, at every level.
Appearance colorsExactly #rrggbb (six hex digits).
pill_radius / cta_radiusInteger, 0 to 24 / 0 to 32.
cta_labelString, trimmed, at most 60 characters.
Appearance enumsOnly the values listed in Surface 4.

Display-only tier rows

A tier row with a zero-value percent discount, for example { "qty": 1, "discount": { "type": "percent", "value": 0 } }, is legal and grants nothing. It exists so a ladder can render a full-price "Buy 1" card next to the discounted tiers. Only tier rows may carry a zero value; pairing discounts are always at least 1.

Display math parity

Every renderer of an offer, server-side Liquid, client JavaScript, and the pricing engine itself, must implement the Surface 3 formulas identically: integer cents, half-away-from-zero rounding, both clamps. Do not improve the rounding in one place. A one-cent divergence between the server-rendered price and the JavaScript-updated price on a variant change reads as a bug to a shopper. Any change to the formulas is a contract change and belongs in the changelog below.

Guarantees and rules

Blocks versus custom sections

Two ways to put bundles on a storefront, both against the same surfaces and the same pricing function.

Use the built-in app blocks

The Ignis app ships theme app blocks for a quantity tier ladder and for a pairing offer. A merchant adds them in the theme editor and styles them from the Ignis app's appearance editor: colors, layout, width, button shape and wording, swatch behavior (Surface 4). No code. The blocks read Surface 1 directly, implement Surface 2 for the pairing add-to-cart, and render Surface 3 math. They render nothing when the feature is not configured for that product, which means a store with bundles switched off shows no empty scaffolding.

Build your own sections

When you need full control of the markup, build your own. Read shop.metafields.ignis.bundles_config.value in any section or snippet, render whatever UI you want from it, and use the cart line protocol for the add-to-cart. Nothing is held back: your section reads the same config the app blocks read and gets priced by the same function.

You can also skip Surface 1 entirely. If your UI only needs to add a pairing and show a saving, the cart line protocol and the display formulas are enough on their own, using product data your theme already has.

Whichever you pick, pricing is identical. The blocks have no privileged path, and a custom section is not second class.

Works well with AI coding tools

The contract is deliberately small. Four surfaces, one JSON shape, one line item property, and a handful of formulas. That is the entire integration.

It is small on purpose. A coding assistant working inside your theme repository can build a complete custom bundle UI from this page alone, with no access to the Ignis codebase and no back and forth about undocumented behavior. Point it at this page, describe the UI you want, and check the result against the display math above.

One instruction worth passing along: the assistant should never reimplement pricing. Every number it renders is a prediction of what the function will do, and the function is what actually charges.

Changelog

VersionDateChange
v13 September 2026Additive: a 14th appearance key, image_size (int, 48 to 200, default 120), plus the CSS custom property that carries it, --ig-bp-img. The pairing block's product photo now fills the row's actual height instead of sitting in a fixed square, so a row made taller by wrapping size options no longer leaves it floating in empty space.
v126 August 2026Additive: Surface 4 (the appearance object, 13 keys), the app-block restyle contract (CSS custom properties and data attributes), and the four swatch paths including the swatch-<handle> file convention. Fixed an omission: the "every key present" shape previously lacked appearance.
v17 August 2026Additive: documented the server validation bounds, display-only zero tier rows, and the display math parity rule. No key changed meaning.
v13 August 2026Config surface published as the shop-owned metafield ignis.bundles_config, readable from any theme code.
v13 August 2026First published contract. Config metafield, cart line protocol, display math.

Breaking changes get a new version number and stay listed here. Additive changes are noted against the version they landed in.

Questions

Integration questions, or something on this page that does not match what you see in a store: [email protected].