Skip to content

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.

LayerFieldsEvaluated againstImportant distinction
Eligibilityconditions ({ combinator: "AND" | "OR", conditions: [...] })The whole cart: $('cart'), $('customer'), $('context'), $('time'), $('couponCodes'), resolved resourcesPass or fail for the whole promotion. An empty AND group always passes.
DiscountapplicationMethod (standard, buyget, custom_script)One item at a time ($('item')) in targetRules/buyRules, or the full context in a scriptSelects which units get money off and computes the amount. Matching conditions but no qualifying items gives computation_no_discount.
Constraintsstatus, validFrom/validTo, requiresCoupon, maxTotalUses, maxUsesPerUser, budgetCapPromotion counters and the requestThese 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.

methodTypeUse whenKey fields
standardPercentage or fixed amount off items, the order, or shippingdiscountType (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_scriptThe discount cannot be expressed with the two above: tiered math, wallet point redemptionlanguage, 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. Fixed 500 each 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 fixed discount with targetType: "order" is always treated as across.
  • percentage values must be at most 100. No unit can go below zero.
  • maxQuantity caps how many units qualify. With across the cheapest units are kept; with each the most expensive are kept.
  • buyget gives the discount to the cheapest target units. A unit that is paid for as a “buy” unit is never also the free unit.

status is one of draft, active, paused, expired, depleted. New promotions default to draft.

StatusSet byApplies at checkout?
draftDefault on create; duplicate also creates a draftNo. Preview with includeInactive/includeInactiveIds
activeYou (PATCH with "status": "active")Yes, if every other constraint passes
paused, expiredYou. Passing validTo does not change the status; the engine reports reason expired from the datesNo
depletedThe engine, when a recorded checkout reaches maxTotalUses or budgetCap. It returns to active if a cancellation frees capacityNo

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.

  • Validity: validFrom/validTo are ISO-8601 timestamps with an offset. validFrom must be before validTo. Outside the window the reason is not_yet_active (with startsAt) or expired.
  • Coupons: with requiresCoupon: true the promotion only runs when the request’s couponCodes contains a valid code that belongs to this promotion. A coupon unlocks the promotion but does not bypass its conditions.
  • Usage caps: maxTotalUses counts recorded checkouts. maxUsesPerUser needs cart.userId; it is enforced on previews too, so a storefront never shows a discount that checkout will refuse. Coupons have their own maxUses and maxUsesPerUser.
  • Budget: budgetCap is a total discount budget in cents. A discount larger than the remaining budget is scaled down to fit, the trace marks it budgetClamped, and a warning is added. A fully spent budget gives budget_exhausted.
  1. Candidates are sorted by priority, highest first (default 0). Ties keep creation order.
  2. Each candidate is priced against the undiscounted cart. This provisional amount is what stacking compares.
  3. Stacking decides which matched promotions survive.
  4. 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.
  5. Total item discounts are capped at the cart subtotal.
stackingModeBehavior
stackable (default)Applies alongside everything else, unless an exclusive promotion wins
exclusiveIf any exclusive promotion matches, exactly one promotion applies in total: the highest-priority exclusive. Every other matched promotion is excluded, stackable ones included
exclusive_groupCompetes 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.

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:

FieldMeaning
subtotalSum of item unit prices including modifiers (and weight). The request’s own subtotal is ignored
shippingTotal, taxTotal, totalShipping is the sum of shippingMethods[].amount (or shippingTotal if no methods are sent). total = subtotal + shippingTotal + taxTotal
itemCount, uniqueItemCountUnits after quantity expansion; distinct item ids
categories, brands, collections, tagsUnion across items
hasModifiers, hasWeightedItems, hasPreOrders, hasSubscriptions, hasBundles, hasGiftItems, modifierCount, totalWeightKgFlags and aggregates
salesChannel, storeId, region, currencyCodeCopied from the request
itemsNormalised units: itemId, price, basePrice, productId, category, tags, … (see discount scripts)
redemptionsThe request’s cart.redemptions

Other namespaces:

  • $('customer') (alias $('user')) is cart.customer, or { id: cart.userId } when only userId is sent.
  • $('context') is cart.context (UTM fields, device, isFirstOrder, timezone, any custom keys).
  • $('time') has hour, dayOfWeek (0 = Sunday), dayOfMonth, month, year, isWeekend, timestamp, in cart.context.timezone or 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.

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.