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.
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.
| Property | Value |
|---|---|
| Namespace | ignis |
| Key | bundles_config |
| Type | json |
| Owner | The shop |
| Liquid path | shop.metafields.ignis.bundles_config.value |
| Written | On 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
| Field | Meaning |
|---|---|
tiers_v2.enabled | Master switch for quantity tiers. When false, no tier discount is ever granted and tier UI should render nothing. |
tiers_v2.display | How 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_list | Product GIDs the mode applies to. Ignored when the mode is "all". |
tiers_v2.default | The default ladder. Each entry is a qty threshold and a discount. Sorted ascending by qty. |
tiers_v2.overrides | Per-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.enabled | Master switch for pairings. |
pairings.list[].page_product | The 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_product | The partner product GID. |
partner_handle, partner_title, partner_price_cents | The 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_ids | Products that already carry their own multi-item price, such as a pre-priced pack. |
sale_stacking | Whether bundle discounts stack on top of a running store sale. See the note under display math. |
Semantics you must get right
- A tier
"amount"value is cents off each unit.{ "qty": 3, "discount": { "type": "amount", "value": 500 } }means buy 3 and save $5.00 on every one of them, so $15.00 off the group. - A pairing
"amount"value is cents off the pair total, not off each side.{ "type": "amount", "value": 1000 }is $10.00 off the two items together. "percent"values are whole percents.10means 10 percent, not 0.1 and not 1000 basis points.- Products listed in
bundle_product_idsnever receive quantity-tier discounts, because their pack price already is the deal. They may still appear as a member of a configured pairing. - Product identifiers are always full GIDs in the form
gid://shopify/Product/1234567890. In Liquid, build one with{% assign gid = 'gid://shopify/Product/' | append: product.id %}.
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
- Lines are grouped by
_bundle_id. Within a group, the pricing function looks for a configured pairing whose two members are both present. - A group earns one pairing discount. The first configured pairing fully present in the group wins.
- The number of discounted sets is
min(qty of member A, qty of member B)inside that group. - If a shopper removes one member, the pair is no longer intact, the discount stops, and the remaining line automatically reverts to standard pricing and counts toward quantity tiers like any other line. Nothing in your code has to detect or clean up a broken pair.
- Lines with no
_bundle_idcan never form a pairing. They only ever pool into quantity tiers. - Every unit gets at most one deal. Units consumed by an intact pairing are removed from the pool before tiers are calculated.
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
- Percent is clamped at 90. Any configured percent above 90 is treated as 90. A negative percent is treated as 0.
- Total discount is clamped at the unit price. A line can go to zero but never below, so no discount can ever produce a negative price.
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
| Key | Type | Default | Meaning |
|---|---|---|---|
accent | #rrggbb | #1b1b1b | Selection and emphasis color: the selected pill and swatch ring, the add-to-cart face, the tier ladder's best-value marker. |
badge_color | #rrggbb | #1b1b1b | The savings badge and savings amounts. |
text_color | #rrggbb | #1b1b1b | Body text on the bundle surfaces. |
pill_radius | int, 0 to 24 | 6 | Corner radius in px of the size pills. Colour swatches are always circles and are not affected by this key. |
image_size | int, 48 to 200 | 120 | Width 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_layout | row | stack | row | Tier ladder direction. |
pairing_layout | stack | inline | inline_reverse | stack | Pairing card: products stacked, side by side, or side by side with the partner product first. |
block_max_width | full | wide | medium | narrow | full | Maximum width of the block. |
block_align | left | center | right | center | Where a non-full-width block sits. |
cta_width | full | auto | full | Add-to-cart button: full width of the card, or shrink to fit its label. |
cta_radius | int, 0 to 32 | 8 | Add-to-cart button corner radius in px. |
cta_label | string, up to 60 chars | "" | Custom add-to-cart wording. Empty means the built-in wording. |
swatch_mode | auto | image | color | auto | What 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_source | shopify_swatches | variant_image | files | variant_image | Where swatch images come from. The whole story is in Swatches, four ways. |
Two rules make old configs and new consumers coexist safely:
- Unknown enum value: render the default. If a key holds a value your code does not recognize (a newer app wrote a value your copy of the theme predates), treat it exactly as the default. Never render nothing because of an appearance value.
- Defaults are the pre-appearance rendering, with one exception. Every default above is what the blocks rendered before this surface existed, except
image_size: the old fixed photo size was a bug (it did not grow with a taller row), so its default of120is the fixed size, not the old rendering. A merchant who never opens the appearance editor still sees this one change.
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 / property | Values |
|---|---|
data-w | full | wide | medium | narrow |
data-a | left | center | right |
data-l | stack | inline | inline_reverse |
data-sm | auto | image | color |
--ig-bp-accent, --ig-bp-badge, --ig-bp-text | colors |
--ig-bp-r | px, the size-pill radius |
--ig-bp-img | px, 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-r | px, the button radius |
--ig-bp-cta-w | 100% 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... | Pick | Setup |
|---|---|---|
| Already uses Shopify's built-in swatches (colours or images attached to option values in the admin) | shopify_swatches | None |
| Has a good product photo per colour variant | variant_image | None |
| Wants purpose-made swatch tiles (fabric close-ups, patterns) | files | Upload one file per colour value |
| Has its own swatch system in the theme | Bring your own | Ignore 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 value | Files looked for |
|---|---|
Black | swatch-black.jpg, then swatch-black.png |
Navy Blue | swatch-navy-blue.jpg, then swatch-navy-blue.png |
Black/White | swatch-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.
| Rule | Bound |
|---|---|
| Percent discount value | Integer, 1 to 90. Tier rows may also be 0, see below. |
| Amount discount value | Integer cents, 1 to 100000 ($1,000.00). |
Tier qty | Integer, 1 to 100. |
| Tiers per ladder | At most 10. A duplicate qty is rejected. Stored sorted ascending. |
| Pairings | At most 200, one per page_product, and the two members must be distinct products. |
product_list, bundle_product_ids, overrides | At most 500 entries each. |
| Product ids | Full GIDs matching gid://shopify/Product/<digits>, nothing else. |
| Unknown keys | Rejected anywhere in the config, at every level. |
| Appearance colors | Exactly #rrggbb (six hex digits). |
pill_radius / cta_radius | Integer, 0 to 24 / 0 to 32. |
cta_label | String, trimmed, at most 60 characters. |
| Appearance enums | Only 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
- The function is the single source of pricing truth. Your templates are display only. If your math and the checkout total disagree, the checkout total is correct and your math has a bug.
- The engine fails closed. Missing, empty or unparseable config produces no discount at all, never an accidental one.
_bundle_idis client-supplied and grants nothing. It is a grouping marker, not a token. It is never treated as proof of anything, and it can only ever cause an offer the merchant has already configured to apply. A shopper who invents their own_bundle_idgets exactly the same price as one who does not. Do not build any trust or authorization on it, in your code or in ours.- Config changes propagate on save. Every save in the Ignis app rewrites the config you read and the config the pricing function reads in the same operation, so the two can never drift. There is no publish step and no cache for you to bust.
- Every key is always present. Config is normalized on write, so you can read a path without defending against a missing intermediate object. Still guard on
enabled, and still render nothing when a ladder is empty. - Pairings are directional. An offer configured as A plus B belongs on A's page. If a merchant wants it on both pages, they configure it twice.
- Appearance is display only. No
appearancekey ever reaches the pricing function. A wrong colour can never be a wrong charge. - Unknown enum values render the default. A consumer must never blank out because an appearance key holds a value it does not recognize.
cta_labelis untrusted text. The blocks escape it; a custom section must too.- Additive changes only within a contract version. New keys may appear in a minor revision. Existing keys will not change meaning or type without a new contract version and an entry in the changelog below.
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
| Version | Date | Change |
|---|---|---|
| v1 | 3 September 2026 | Additive: 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. |
| v1 | 26 August 2026 | Additive: 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. |
| v1 | 7 August 2026 | Additive: documented the server validation bounds, display-only zero tier rows, and the display math parity rule. No key changed meaning. |
| v1 | 3 August 2026 | Config surface published as the shop-owned metafield ignis.bundles_config, readable from any theme code. |
| v1 | 3 August 2026 | First 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].