Promotions
A promotion has three parts. Conditions decide whether the cart qualifies. The applicationMethod decides which items (or shipping) get how much off. Constraints limit when and how often it can apply: status, validity window, coupon requirement, usage caps and a spend budget. The engine evaluates every live promotion against a cart you send to POST /projects/{projectId}/promotions/evaluate. It returns per-line discounts and a reason for each promotion that did not apply.
All money is integer minor units (cents). 5000 means $50.00 for a USD cart. This covers cart prices, condition thresholds, fixed discount values, budgetCap and every amount in the response.
The three layers
Section titled “The three layers”| Layer | Fields | Evaluated against | Important distinction |
|---|---|---|---|
| Eligibility | conditions ({ combinator: "AND" | "OR", conditions: [...] }) | The whole cart: $('cart'), $('customer'), $('context'), $('time'), $('couponCodes'), resolved resources | Pass or fail for the whole promotion. An empty AND group always passes. |
| Discount | applicationMethod (standard, buyget, custom_script) | One item at a time ($('item')) in targetRules/buyRules, or the full context in a script | Selects which units get money off and computes the amount. Matching conditions but no qualifying items gives computation_no_discount. |
| Constraints | status, validFrom/validTo, requiresCoupon, maxTotalUses, maxUsesPerUser, budgetCap | Promotion counters and the request | These are checked before conditions run. A promotion that fails one never reaches conditions, and its trace entry is pre_filtered. |
Put cart-wide rules (“subtotal ≥ 5000”, “customer tier is gold”) in conditions. Put item selection (“only shoes”) in targetRules. If you put an item rule in conditions, it can only test cart-level aggregates such as $('cart').categories.
Application methods
Section titled “Application methods”methodType | Use when | Key fields |
|---|---|---|
standard | Percentage or fixed amount off items, the order, or shipping | discountType (percentage|fixed), value, targetType (items|order|shipping), allocation (each|across), optional targetRules, maxQuantity |
buyget | “Buy N of X, get M of Y at a discount” | buyRules, buyQuantity, targetRules, targetQuantity, discountType, value |
custom_script | The discount cannot be expressed with the two above: tiered math, wallet point redemption | language, script. See discount scripts |
The engine expands each cart line into one unit per quantity. A line with quantity: 3 is three units.
allocation: "each"applies the full value to every qualifying unit. Fixed500each on a quantity-3 line gives 1500 off.allocation: "across"computes one amount over the qualifying units and splits it in proportion to price.- A
fixeddiscount withtargetType: "order"is always treated asacross. percentagevalues must be at most 100. No unit can go below zero.maxQuantitycaps how many units qualify. Withacrossthe cheapest units are kept; witheachthe most expensive are kept.buygetgives the discount to the cheapest target units. A unit that is paid for as a “buy” unit is never also the free unit.
Status lifecycle
Section titled “Status lifecycle”status is one of draft, active, paused, expired, depleted. New promotions default to draft.
| Status | Set by | Applies at checkout? |
|---|---|---|
draft | Default on create; duplicate also creates a draft | No. Preview with includeInactive/includeInactiveIds |
active | You (PATCH with "status": "active") | Yes, if every other constraint passes |
paused, expired | You. Passing validTo does not change the status; the engine reports reason expired from the dates | No |
depleted | The engine, when a recorded checkout reaches maxTotalUses or budgetCap. It returns to active if a cancellation frees capacity | No |
There is also a boolean isActive (default true). When it is false the promotion is never loaded for evaluation, even with includeInactive. Use status to control a campaign and leave isActive alone.
Constraints
Section titled “Constraints”- Validity:
validFrom/validToare ISO-8601 timestamps with an offset.validFrommust be beforevalidTo. Outside the window the reason isnot_yet_active(withstartsAt) orexpired. - Coupons: with
requiresCoupon: truethe promotion only runs when the request’scouponCodescontains a valid code that belongs to this promotion. A coupon unlocks the promotion but does not bypass its conditions. - Usage caps:
maxTotalUsescounts recorded checkouts.maxUsesPerUserneedscart.userId; it is enforced on previews too, so a storefront never shows a discount that checkout will refuse. Coupons have their ownmaxUsesandmaxUsesPerUser. - Budget:
budgetCapis a total discount budget in cents. A discount larger than the remaining budget is scaled down to fit, the trace marks itbudgetClamped, and a warning is added. A fully spent budget givesbudget_exhausted.
Order, compounding and stacking
Section titled “Order, compounding and stacking”- Candidates are sorted by
priority, highest first (default0). Ties keep creation order. - Each candidate is priced against the undiscounted cart. This provisional amount is what stacking compares.
- Stacking decides which matched promotions survive.
- Survivors are re-priced in priority order. Each one sees the prices left by the promotions before it, so a second 10% applies to the already-discounted price.
- Total item discounts are capped at the cart subtotal.
stackingMode | Behavior |
|---|---|
stackable (default) | Applies alongside everything else, unless an exclusive promotion wins |
exclusive | If any exclusive promotion matches, exactly one promotion applies in total: the highest-priority exclusive. Every other matched promotion is excluded, stackable ones included |
exclusive_group | Competes only with matched promotions that share its stackingGroup (a null group is one shared default group). One winner per group; stackable promotions still apply |
A tie on priority goes to the larger provisional discount. Losers appear in notApplied with reason excluded_by_stacking, and the trace’s excludedBy names the winner.
What expressions can read
Section titled “What expressions can read”Condition leftValues are expressions wrapped in {{ }}, for example {{ $('cart').subtotal }}. Operators are covered in conditions. Label comparisons (category, categories, brand, tags, collections) with equals/notEquals/contains/notContains ignore case and surrounding spaces. IDs and SKUs are compared exactly.
$('cart') is the cart the engine derived. It is not your raw request:
| Field | Meaning |
|---|---|
subtotal | Sum of item unit prices including modifiers (and weight). The request’s own subtotal is ignored |
shippingTotal, taxTotal, total | Shipping is the sum of shippingMethods[].amount (or shippingTotal if no methods are sent). total = subtotal + shippingTotal + taxTotal |
itemCount, uniqueItemCount | Units after quantity expansion; distinct item ids |
categories, brands, collections, tags | Union across items |
hasModifiers, hasWeightedItems, hasPreOrders, hasSubscriptions, hasBundles, hasGiftItems, modifierCount, totalWeightKg | Flags and aggregates |
salesChannel, storeId, region, currencyCode | Copied from the request |
items | Normalised units: itemId, price, basePrice, productId, category, tags, … (see discount scripts) |
redemptions | The request’s cart.redemptions |
Other namespaces:
$('customer')(alias$('user')) iscart.customer, or{ id: cart.userId }when onlyuserIdis sent.$('context')iscart.context(UTM fields,device,isFirstOrder,timezone, any custom keys).$('time')hashour,dayOfWeek(0 = Sunday),dayOfMonth,month,year,isWeekend,timestamp, incart.context.timezoneor UTC.$('couponCodes')is the request’s code array.
$('item') exists only inside targetRules and buyRules. Its fields are productId, variantId, sku, unitPrice (the line’s unitPrice), effectivePrice (including modifiers), quantity (the line quantity), category, categories, collections, brand, tags, attributes, isGift, isPreOrder, isSubscription, hasModifiers, bundleId, modifierNames, modifierGroups, modifierTotal, weightValue, weightUnit and compareAtPrice. standard also adds lineTotal. There is no itemId. A shipping-targeted standard method uses $('shipping') (id, name, amount, carrier, type) in its targetRules.
Item metadata is not exposed to conditions or scripts. Anything you want to match on must go in attributes, tags, or a named field.
Resources
Section titled “Resources”A promotion’s resources object names project resources to load before evaluation. The keys are resource keys; the values are only sample data for the dashboard editor. Each resolved resource is readable as $('<key>'), so a wallet resource gives you the customer’s balance in conditions and scripts. Resources are not created automatically: define the resource on the project first. A resource that fails to resolve becomes { _error: "..." } rather than failing the evaluation. The built-in namespaces (cart, customer, context, time) always override a resource with the same key.
See also
Section titled “See also”- Creating promotions: a worked example with real request bodies
- Conditions: operators and condition syntax
- Discount scripts
- Checkout integration