Sales
This page shows a store administrator how to run an automatic discount. A sale needs no code, applies to every shopper, runs on a schedule, and changes what the catalog says a product costs: the regular price struck through, the sale price beside it, and a badge naming the campaign on the grid, on the product page and in the buy button.
A coupon is a code a customer types. A sale is a price the store is charging. The two work together, and the rules for that are in Sales and coupons together.
Before you begin
- The
kahunacart.sales.managepermission on whoever runs promotions. It is separate fromkahunacart.settings, so the person who runs a campaign does not need access to payment credentials or tax zones. - At least one product to discount. See Products and catalog.
What a sale is
A sale is a header, a set of rules, and optionally a set of conditions. The header is what you name, schedule and switch off.
| Field | Admin label | What it does |
|---|---|---|
name |
Name | Your handle for the campaign — "Summer clearance". Never shown to a customer. |
label |
Badge | What a customer reads: the badge on a card and the discount line on the receipt. Translatable. |
enabled |
Enabled | Off means the campaign does nothing, whatever its dates say. |
starts_at |
Starts | UTC epoch seconds. Blank means "since always". |
expires_at |
Ends | UTC epoch seconds. Blank means "until you switch it off". |
priority |
Priority | Higher wins when two sales cover the same variant. |
stack_with_coupons |
Coupon codes still work on discounted lines | Whether a customer may use a code on top of this sale. On by default. |
tier_basis |
Count | What a quantity tier counts: units or products. Default units. Ignored on a sale with no tiers. |
The rules are what the sale does. Each names a scope and a discount.
| Field | Admin column | What it does |
|---|---|---|
scope |
Scope | all, category, tag, product or variant, listed as Everything, Category, Tag, Product and Variant. |
scope_id |
Applies to | What the rule points at. Null, and only null, for all. |
mode |
Discount | percent or fixed, listed as Percent off and Fixed amount off. |
percent |
Value | A decimal string — '20', '12.5'. Never a float. |
amount_minor |
Value | A fixed amount off per unit, in minor units. Over the API it is sent as amount, a decimal string. |
exclude |
Exclude | This rule takes things out of the sale rather than discounting them. |
position |
Row order | The order the rules are listed in, which breaks ties. |
operation_code |
Discount | Which operation prices the rule. Optional; mode is the shorthand for the two ordinary ones. |
args_json |
The operation's own fields | That operation's arguments. Null on percent_off and amount_off, which read percent and amount_minor. |
"20% off Clothing, but 30% off the boardshort, and not the socks" is one sale with three rules.
On a store that sells in more than one currency a fixed rule or tier has the same amount off in each other currency, in its own box beside the base one. With typed pricing, a rule with no amount in a currency takes nothing off in that currency, and does not borrow the base amount. A currency set to convert works the amount out from the base one, to that currency's own decimal places, and an amount you typed still wins. A percentage rule needs no boxes, because 20% is 20% everywhere. Saving a sale writes its rules again under new ids, and the amounts go with them: a rule you do not change keeps its amounts, matched by its place, scope and operation, and a tier by its count. See Pricing a product in another currency.
mode and operation_code say the same thing two ways for the two ordinary discounts: percent is percent_off, and fixed is amount_off. Choose a different operation, such as buy-one-get-one, cheapest-free, or one an add-on registered, and the rule takes its settings from args_json instead. See Rules and conditions.
Conditions decide whether the campaign applies at all: "carts over 50.00", "customers who have ordered before", "not the clearance category". They are optional.
A condition about the customer (their group, email, email domain or order count) counts only when the shopper is signed in as that customer, or when staff built the order and attached them: a till sale, or an order made in the admin. A customer who has to sign in to get the Trade sale has proved who they are. A guest who types a Trade member's email, or any @acme.com address, at checkout is priced as a guest, with the store's default group and no email to match. See Rules and conditions.
Important
A sale with conditions is priced at checkout, not on the catalog. It shows no badge, no struck-through price and no place in the On sale filter, because a product card has no cart to judge the conditions against. See Rules and conditions.
The admin shows one of four states per sale.
| Status | Means |
|---|---|
| Active | Enabled and inside its window. Discounting right now. |
| Scheduled | Enabled, but the start date has not arrived. |
| Expired | Enabled, but the end date has passed. |
| Off | Enabled is unticked. |
How a discount is decided
Three questions, asked in this order, for every variant on every page.
1. Which sale?
Sales are considered highest priority first, oldest first after that. On the default setting the first sale that covers the variant wins and the rest are not looked at.
# user/config/plugins/kahunacart.yaml
sales:
overlap: priority # priority | best_for_customer
Set sales.overlap to best_for_customer to evaluate every covering sale instead and take the largest discount, with priority breaking a tie. Use it when your campaigns are independent offers.
2. Which rule inside it?
Rules with no fixed amount in the order’s currency are left out before choosing the most specific rule. A broader priced rule can therefore apply. Exclusions still beat every rule. The most specific eligible rule wins:
variant > product > tag / category > all
Tag and category are the same specificity, and a tie between them goes to whichever rule is listed first.
A category rule reaches the whole subtree. Putting Clothing on sale discounts everything filed under Clothing → Mens → Tops as well.
3. Is it excluded?
An exclusion beats everything. If any rule of a sale marked Exclude matches the variant, that sale does not cover it, even when a narrower rule would otherwise have won.
An exclusion only silences its own sale. A product excluded from the 30% campaign can still be covered by the 10% one running beside it.
A pay-what-you-want product is never discounted, whatever a sale's rules say. Nobody marked the price down, so there's nothing to take off: the line keeps the amount the shopper named, and the catalog draws no badge or struck-through price on it.
The arithmetic
Every amount is computed in minor units. Percentages go through the same single-rounding path coupons and tax rates use, so a 12.5% sale and a 12.5% coupon take the same number of cents off the same line. A fixed amount is capped at what the variant costs: a $10-off rule makes a $6 product free rather than turning it into a $4 refund. The sale price never goes below zero.
Note
A rule that works out to nothing off is not a sale. A percentage that rounds to zero on a very cheap line leaves the product at its ordinary price, with no badge and no strike-through.
Create a sale
- In the admin sidebar, click KahunaCart, then Sales.
- Click New sale.
- Enter a Name for yourself and a Badge for your customers. The badge ends up on receipts, so write it as a customer would read it: "Summer Sale", not "summer-2027-clothing-v2".
- Set Priority if another sale may cover the same products. Leave it at
0otherwise. - Leave Enabled ticked, and leave Coupon codes still work on discounted lines ticked unless this campaign should block codes.
- Under Rules, click Add rule. Choose a Scope, pick what it Applies to, choose a Discount of Percent off or Fixed amount off, and enter the Value. Tick Exclude to take something back out of the sale instead.
- Add the rest of the rules, broadest first, and use the arrows to order them. Order matters only for ties.
- Click Create sale.
The storefront picks the sale up on the next page view. There is no cache to clear and no reprice job to wait for.
Important
Saving replaces the sale's rules with what is in the table, and its tiers with the configured tier list. A sale with no rules discounts nothing.
A blank name is refused with "A sale name is required." and a blank badge with "A sale needs a badge for shoppers to see."
Schedule a sale
- Open the sale in KahunaCart → Sales.
- Under Schedule, set Starts, Ends, or both.
- Click Save sale.
Leave Starts blank to begin immediately. Leave Ends blank for a campaign you will end by hand. Clearing a date clears the column, so a sale with both fields blank runs until you untick Enabled.
To pause a campaign without losing its rules, use Disable in the row's actions menu. That sends the enabled field on its own and leaves the rules standing.
Quantity tiers
A quantity tier sets a discount based on quantity: "buy 2, save 10%; buy 3, save 15%". The sale rules determine which products qualify. The tier determines their discount based on the qualifying quantity in the cart.
Note
A sale's quantity tier sets a discount on the variant's existing price. A price list's quantity tier sets a price per unit at that quantity, and a variant can have its own quantity tiers without a sale. The two are separate features. See Price lists and quantity tiers.
Each tier carries a count, a mode and a value. It has no scope of its own, because the tiers belong to the campaign.
| Field | Admin column | What it does |
|---|---|---|
min_quantity |
Buy at least | The minimum quantity for this tier. Two or more. |
mode |
Discount | percent or fixed, exactly as a rule's. |
percent |
Value | A decimal string — '15', '12.5'. Never a float. |
amount_minor |
Value | A fixed amount off per unit, in minor units. |
position |
Row order | The display order of the tiers. Evaluation always starts with the lowest minimum quantity. |
How a tiered sale is decided
- The sale's rules decide which lines are matched, exactly as they do without tiers.
- The count is taken over the matched lines.
tier_basis: unitscounts every unit across them.tier_basis: productscounts distinct products among them. A product the sale excludes is not part of that sale's count. - The highest tier whose
min_quantityis at or below the count wins. Three units reaches a "buy 3" tier and a "buy 2" tier, and the "buy 3" one applies. - The winning tier replaces the sale's own mode and value for every matched line. It does not add to them. A 5% sale with a "buy 3, save 15%" tier takes 5% off below three and 15% off at three, never 20%.
- Below the first tier the sale's own discount applies. A sale with tiers and no discount on its rules is a tier-only sale, and it takes nothing off below its first tier.
- Everything else is unchanged. Priority, exclusions,
stack_with_coupons, the adjustment source and the cache boundary all behave as they do without tiers. Coupons still compute on the post-sale base, which is now the tiered base.
Note
The count is over the lines a sale's rules match, not the lines it ends up pricing. Those are the same set except where two campaigns overlap.
Write a tier-only sale
To include products in the offer and let the quantity tiers determine their discount, give the rule a value of zero.
- In the admin sidebar, click KahunaCart, then Sales, and click New sale.
- Enter a Name and a Badge, then add a rule scoped to what the offer covers.
- Set that rule's Value to
0. - Under Buy more, save more, click Add tier, then set Buy at least, Discount and Value for each tier.
- Set Count to what the offer measures: Total items in the cart or Different products in the cart.
- Click Create sale.
The sale takes nothing off a single purchase, and the catalog card shows a "Buy 2+ and save" badge instead of a sale price.
Important
A rule may carry a value of zero only on a sale that has tiers. Without quantity tiers, a 0% rule discounts nothing, which is what Exclude is for, and the API refuses it.
A tier must always have a value greater than zero. percent: 0 or amount_minor: 0 on a tier answers 422 naming the tier — "A percent sale tier needs a percentage above zero".
Worked example: Premium Bundle
The offer: "buy any two of our premium downloads and save 10%, any three and save 15%". Scoped to the tag premium, counted by distinct products, with nothing off a single purchase.
sales:
- name: Premium Bundle
label: Bundle and save
priority: 5
tier_basis: products
rules:
- scope: tag
tag: premium
mode: percent
percent: '0'
tiers:
- min_quantity: 2
mode: percent
percent: '10'
- min_quantity: 3
mode: percent
percent: '15'
What a customer sees, with three products tagged premium at £24, £9 and £6:
| Cart | Count | Rung | Discount |
|---|---|---|---|
| One handbook | 1 product | none | Nothing off. The card says "Buy 2+ and save". |
| Three handbooks | 1 product | none | Nothing off. The basis is products, and three of one thing is one product. |
| Handbook + chart | 2 products | buy 2 | 10% off both lines: £2.40 and £0.90. |
| Handbook + chart + log | 3 products | buy 3 | 15% off all three lines: £3.60, £1.35 and £0.90. |
Switch tier_basis to units and three handbooks reach the "buy 3" tier on their own.
This campaign ships in the demo seed as Premium Bundle, so bin/plugin kahunacart seed provides a working example of quantity tiers.
Tiers on the storefront
The product page displays all tiers under the buy button, from partials/kahunacart-sale-tiers.html.twig. No tier is highlighted because the product page does not evaluate the cart quantity.
A catalog card for a tier-only sale draws a one-line badge, "Buy 2+ and save", instead of a struck-through price. A sale with a base discount draws the strike-through it always did.
The cart displays the current tier, a prompt to reach the next one ("Add 1 more to save 15%"), and the full tier list with the current tier highlighted. The prompt is hidden when the cart qualifies for the best rate.
The JSON-LD Offer on a tier-only sale quotes the ordinary price, because price describes the cost of one unit, while quantity tiers depend on the basket.
To restyle the tier list, copy partials/kahunacart-sale-tiers.html.twig into your theme. See Theming.
Sales in the cart and at checkout
The cart line keeps the regular price. Nothing rewrites unit_price_minor, so a receipt can show both numbers. The discount arrives as one adjustment row per covered line, carrying the item id, exactly as a fixed_product coupon does.
The adjustment's source records the sale id, the rule id, the mode, the rule's own number, the base it was computed on, the per-unit discount, whether coupons may stack, and on a tiered sale the tier id, the count the cart reached and what was being counted.
The cart page also gets a sale_tiers variable: one entry per applicable set of tiers, containing the label, qualifying count, current tier, next tier, and the additional quantity needed to reach it.
Live rules, frozen price. Whether a line is covered is read from the catalog as it stands now, so filing a product into a discounted category prices the next recalculation. How much comes off is worked out against the line's own unit_price_minor, the number the customer was quoted. Raise a $50 product to $100 with a 20% campaign running and the line still gets $10 off.
A sale ending mid-cart corrects itself. Carts recalculate on every change, so adding an item, changing a quantity or submitting the checkout reprices the whole cart at the ordinary price. The cart's "estimate shipping & tax" box runs the same stage the checkout runs.
Discounts are capped at the order's headroom, items plus whatever adjustments have already landed, so a sale can take a cart to zero and never past it.
Sales and bundle prices
A grouped product or a kit can be sold as a set, and the cart works out that price in the same step as sales. A unit never gets both. For the units a set would take, the cart compares what the set saves with what those units' sales save, and the customer gets the better of the two. When the set wins, the units in it lose their sale and any left over keep theirs. With 20% off the wonky clip, one clip and two wonky ones cost the £28.50 set plus £12.00 for the second wonky clip. When the sales save as much or more, no set is formed and the sales stand.
After that step a bundle price's rows count as sale rows: a coupon measures the line after them, and a code marked Skip items already on sale leaves those lines alone. The cart shows one row per set-priced product, named after it.
Sales and coupons together
A sale and a coupon are two separate things. A sale is catalog-visible: it changes what the grid, the product page and the buy button say a product costs. A coupon is invisible until the shopper types the code.
A coupon comes off the sale price, not the list price. 20% off a $50 shirt that is already half price is $5, not $10.
Every base the coupon stage measures is the line's total minus whatever a sale or a bundle price has taken off it. That applies to all three coupon types: a percentage, a cart-wide fixed amount, and a per-line fixed amount, which is capped at the discounted line rather than the original. The cart-wide type is measured over the same lines as the other two, not over the whole cart. A fixed_cart code writes one order-level adjustment, but the money comes off the lines underneath it, so a $60 code on a cart holding $10 of ordinary goods beside $100 of stock a non-stacking sale claimed takes $10.
The order is fixed: sales first, then coupons. It is not configurable. A merchant who wants both percentages measured against the catalog price asks for it on the coupon, with the list basis, one code at a time. See Coupons.
One sale covers a line, not several. Where two campaigns both cover a variant, sales.overlap decides which one wins, and the loser contributes nothing. Sales do not stack with each other in this release. A coupon on the list basis is how a second layer of discount is expressed today.
When Coupon codes still work on discounted lines is unticked, the lines that sale covers are removed from the coupon's reach entirely. A coupon with other lines to work on applies to those. A coupon left with nothing is refused with "That code can't be combined with the current sale.", flashed on the cart like an expired code. The sale itself is unaffected.
Sales on the storefront
The badge and the strike-through
A product a sale covers renders its regular price inside <s class="kahunacart-compare-price"> with the sale price beside it, plus a .kahunacart-sale-badge element carrying the campaign's badge text. It falls back to PLUGIN_KAHUNACART.SALE_BADGE ("On sale") for a campaign with no badge of its own.
Important
While a sale covers a variant, compare_price_minor is ignored. The strike-through shows the regular price, what a shopper would pay today if the campaign were not running.
The option picker
Sales are per variant, so the picker swaps three things together when a shopper changes an axis: the price, the struck-through regular price, and the badge. Each string is formatted in PHP in the site's locale and shipped down with the page. A variant no campaign covers puts all three back.
Structured data
The JSON-LD Offer quotes the sale price, and an AggregateOffer spans the discounted range, so the markup matches the number on the page. A tier-only sale is the exception: see Tiers on the storefront.
The on-sale filter
?sale=1 narrows a listing to the products a campaign covers. The checkbox appears in the filter panel only while a sale is running, the same presence rule every other facet follows.
- The price boxes keep measuring the regular price.
minandmaxfilter on what a product ordinarily costs, discounted or not, so a bookmarked price range means the same thing every week. - A catalog-wide sale adds no constraint, because everything qualifies. If such a sale carries exclusions, the excluded products are subtracted, up to about five thousand published products. Past that the facet stops constraining and lists a handful of extras, and the same cap applies to any list the facet arrives at. Cards that slip through carry no badge.
- A variant exclusion does not take its product out of the listing. A product with one size left out of a sale still has something discounted in it. A product whose every variant is excluded does come off the list.
Caching
An anonymous catalog page keeps whatever system.pages.expires says, capped at the next moment a sale starts or stops. A store with no campaigns scheduled has no boundary ahead, so the configured lifetime stands.
Twig reference
Every product and variant the storefront hands a template carries a sale key: null at the ordinary price, otherwise an array. A theme building its own grid can ask directly.
{% set offer = kahunacart_sale(variant, product) %}
{% if offer.has_discount %}
<s>{{ kahunacart_price(offer.regular_minor, with: offer.sale_minor) }}</s>
<span>{{ kahunacart_price(offer.sale_minor, with: offer.regular_minor) }}</span>
<span class="kahunacart-sale-badge">{{ offer.label }}</span>
{% endif %}
Important
Test offer.has_discount, not offer on its own, before you print a struck-through price. A tier-only sale is a real offer with no money off until the cart reaches its first tier, and striking a price through against it prints the same number twice.
| Key | What it holds |
|---|---|
sale_id |
The sale that decided the price. |
rule_id |
The rule inside it that won. |
label |
The badge text, already translated for the language being browsed. |
regular_minor |
The catalog price, in minor units. |
sale_minor |
The discounted price, in minor units. |
discount_minor |
The difference, per unit. Multiplying by quantity is the cart's job. |
mode |
percent or fixed. |
value |
The rule's own number as text. |
stack_with_coupons |
Whether a code may be applied on top. |
has_discount |
Whether this offer takes money off right now. False on a tier-only sale below its first tier. |
tiers |
All tiers, lowest quantity first, each entry carrying id, min_quantity, mode and value. |
tier_basis |
units or products. |
tier_count |
The qualifying quantity used to select the tier. |
current_tier |
The tier a cart qualifies for. null on a product page. |
The rules are loaded once per request and every later question is answered from memory, so a grid of fifty cards costs what a single product page costs.
Translate the label
The badge is translatable the same way a shipping method's or a tax rate's label is. Open the sale, use the language tabs above the header fields, and fill in the badge for each language. An empty field falls back to the base. The name is never translated.
A sale's line on an order is resolved against the language the order was shopped in, not the language of whoever is looking at it. A webhook completing an order and a merchant recalculating one from the admin both produce the words the customer read when they bought. See Multilanguage.
The storefront resolves the badge against the language being browsed, falling back to the site default and then to the stored badge.
In another currency
A percentage works on the price the variant has in the cart's currency, so 20% off 18.50 in euro is 3.70, whatever the dollar price is. A fixed amount is a number in one currency, so a fixed rule needs an amount for each currency it should work in. For any currency other than the base the amount is a row in kahunacart_currency_amounts, and a fixed rule with no amount in the currency gives nothing there: no discount, no badge and no struck-through price, never zero off and never the dollar amount. A percentage rule needs no row.
The rows are written under these owners and fields:
| Where the fixed amount is | Owner type | Field |
|---|---|---|
A rule's amount_minor |
sale_rule (the rule's id) |
amount_minor |
The amount_minor inside a rule's args_json |
sale_rule (the rule's id) |
args.amount_minor |
A fixed rung of a quantity-tier ladder in a rule's args_json |
sale_rule (the rule's id) |
args.tiers.<min>.amount_minor, where <min> is the rung's minimum quantity |
A fixed rung of a sale's own tiers (a row of kahunacart_sale_tiers) |
sale_tier (the tier's id) |
amount_minor |
A fixed rung with no amount in the currency is not a rung in it, so the ladder in euro is shorter than the ladder in dollars. A rule's or tier's id changes whenever the sale is saved, so whatever writes these rows writes them again in the same save.
An operation of its own that keeps money in its arguments gets a row for each one, under args.<key>: an operation whose validated arguments hold bonus_minor and cap_minor has args.bonus_minor and args.cap_minor. A rule needs an amount in the currency for every one of them, and with one missing it gives nothing there, never half of itself.
Writing them. Through the API a rule takes currency_amounts named for the operation's money arguments, and each fixed rung of a ladder in the rule's arguments carries its own:
{ "operation_code": "quantity_tiers",
"args": { "basis": "units", "tiers": [
{ "min_quantity": 2, "mode": "fixed", "amount": "1.00", "currency_amounts": { "EUR": { "amount": "0.70" } } },
{ "min_quantity": 4, "mode": "percent", "percent": "20" } ] } }
A rule saved again without them keeps what it had, an amount sent again replaces it, and an empty one takes it away. A rung that was removed or turned into a percentage loses its amounts with it, so a sale saved a hundred times leaves no stray rows. In the sale editor, a rule that prices through a quantity ladder is a table of rungs with a box per currency beside each fixed one, and every money argument of an operation has a box per currency under it, drawn from the operation's own declaration, so an add-on's operation gets them without any code of its own. An empty box means the rule takes nothing off in that currency.
After extra currencies are disabled, editing an existing sale keeps its saved amounts, named money arguments and fixed ladder rungs. Its existing quantity ladder remains editable. Removing a rule or rung, or changing a rung to a percentage, removes that rung’s foreign amounts too. A new rule on a store with extra currencies can choose Quantity tiers in the discount-type picker.
Import and export
Sales travel in the catalog YAML, merged by name.
sales:
- name: Summer Sale
label: Summer Sale
priority: 10
rules:
- scope: category
category: clothing
mode: percent
percent: '20'
- scope: product
product: sandbar-boardshort
mode: percent
percent: '30'
- scope: product
product: saltwater-socks
exclude: true
A sale's own tiers (the Buy more, save more table) and its tier_basis are not in the file today. They are set in the sale editor or through the API.
A rule that prices through an operation carries an operation: code and an args: block instead of mode and its number.
rules:
- scope: tag
tag: socks
operation: bogo
args: { buy: 1, get: 1, percent: '100' }
A file naming an operation the receiving store has no plugin for is refused by name rather than imported as a rule that discounts nothing.
A fixed rule's amounts in other currencies travel as amounts: { EUR: '4.50' } beside its amount, written only when it has some, and a file with none is the file it always was. A ladder that a rule keeps in its args writes amounts on each fixed rung the same way:
rules:
- scope: product
product: wax-bar
operation: quantity_tiers
args:
basis: units
tiers:
- { min_quantity: 2, mode: fixed, amount_minor: 100, amounts: { EUR: '0.70', JPY: '100' } }
- { min_quantity: 4, mode: percent, percent: '20' }
The money arguments of an operation of its own travel in the file only for the one called amount, as the rule's amounts. An operation with others keeps their base values in args, and a rule imported on a store that has no amounts for them gives nothing in the other currencies until you enter them.
Every rule names its target by slug (category, product), by label (tag), or by its product's slug and its own SKU (variant), never by a row id. A rule naming something the receiving store does not have is dropped rather than fatal. Coupons stay out of the file, because a code is issued to a person and its redemption log is customer data.
The demo seed ships two campaigns: 20% off Clothing with the boardshort at 30% and the socks excluded, and the tiered Premium Bundle above. bin/plugin kahunacart seed gives you both kinds to look at, and seed --purge removes them again by name. See Products and catalog.
API reference
All sale endpoints require kahunacart.sales.manage, not kahunacart.settings.
GET /kahunacart/sales
POST /kahunacart/sales # {name, label, ...header, rules: [...], tiers: [...], translations: {...}}
GET /kahunacart/sales/{id}
PATCH /kahunacart/sales/{id} # header fields, rules, tiers, translations — only what is sent is touched
DELETE /kahunacart/sales/{id}
A sale is always read and written with its rules and its tiers. name and label are both required on create and neither may be blank. The header also takes starts_at, expires_at, priority, enabled, stack_with_coupons and tier_basis. The two dates are nullable, so clearing one clears the column rather than writing a zero.
A rule object takes scope, scope_id, mode, percent, amount and exclude. percent is a decimal string. amount is a decimal string in the base currency, stored in minor units, and a rule comes back carrying both amount_minor and the decimal amount. position is taken from the row's place in the list, not from anything the client sends.
On a store with extra currencies each rule and tier also takes and returns currency_amounts, {"EUR": {"amount": "4.50"}}, and each fixed rung of a ladder in a rule's arguments takes it on the rung. See Admin API.
A tier object takes min_quantity, mode, percent and amount.
PATCH with no rules key updates the header and leaves the rules alone, which is what the enable toggle sends. PATCH with a rules array replaces the whole set inside one transaction, an empty list included. That is how the last rule is removed; there is no "add a rule" call.
tiers follows the same three-state contract: absent leaves the tiers unchanged, an empty list removes them, and a list replaces them.
An unsupported scope or mode, a percentage outside 0–100, or an amount that is not a decimal number is rejected with a 422 explaining the reason. Invalid tiers are also rejected: a minimum quantity below two, duplicate minimum quantities, or a value that does not match the tier's mode. The admin validates the same restrictions before saving. Deleting a sale removes its rules, its tiers and its label's translations, and prices go back to what the catalog says.
See REST API for authentication and conventions, and Run the store from an AI assistant for the same five operations as assistant tools.
Free gifts
A sale can put a product in the basket rather than taking money off one. Set a rule's operation to free_gift, choose the variant and the quantity, and the cart gets the gift while the sale's conditions hold.
Set up a free gift
- Open Promotions → Sales and create or edit a sale.
- Add a rule and choose the Free gift operation.
- Pick the variant to give away and how many.
- Put the sale's conditions on it, "cart over £50" say. A sale with no conditions gives the gift to every cart. A gift kept to a customer group goes to a signed-in member of that group, and to a customer staff attached to a till sale or an admin order.
- Save.
The gift appears in the basket at zero as soon as the cart qualifies.
The reversibility guarantee
A gift line carries the id of the rule that gave it away, and that id is part of what identifies the line.
| Situation | What happens |
|---|---|
| The cart qualifies | One gift line is added. A second pass finds it and adds nothing |
| The cart stops qualifying | The gift line is removed on the next cart change |
| The cart recalculates ten times | Exactly one gift line, every time |
| The customer presses Remove on the gift | Refused, with a message naming the promotion |
| The customer types a new quantity on the gift | Refused the same way. Typing 0 is the same request as pressing Remove |
| The gift is out of stock | No line is added, and the customer is told with a notice on the cart |
The gift is priced at zero rather than discounted to zero, so the basket never shows a price that is about to be cancelled. Stock is taken for the gift at completion like any other line.
Note
A free gift is the promotion's line, not the customer's. A deletion would be undone by the next recalculation, so it is refused up front.
See Rules and conditions for the operation beside the other five.
Sales are not price lists
A sale is a discount. It applies to every shopper, computes a reduction against the price the variant already had, puts a badge on the product card, strikes the regular price through, and feeds the On sale filter. A price list is a price. It applies to the shoppers its rules name, states the figure rather than deriving one, draws no badge, and appears in no catalog facet.
| Sale | Price list | |
|---|---|---|
| What it stores | A reduction (percent or amount off) | A price |
| Who it applies to | Every shopper | The shoppers the rules name |
| Catalog badge | Yes | No |
| In the On sale filter | Yes | No |
| Quantity breaks | A tiered discount off the base price | Tiered price rows |
Nothing about sales changed when price lists arrived. Nothing composes a price list with a sale, and nothing on the storefront reads a price list yet. See Price lists and quantity tiers.