Appearance
Creating Promotions
What It Is
A promotion is a single discount record with a discountCode, a discountType, and a rules object whose shape depends on the type. The same entity powers promo codes, auto-applied cart discounts, and campaign reward coupons — there is no separate "coupon" or "deal" object.
Discounts are scoped to brands and hubs through discount_hub_brand_relations, which is also where approval lives.
What You Can Do With It
Take a percentage or dollar amount off matching items
LINE_ITEM walks every item in the cart, keeps the ones matching constraints, and discounts each one.
Config:
json
{
"discountCode": "SPRING20",
"discountType": "LINE_ITEM",
"routine": "standard",
"rules": {
"constraints": [{ "restriction": "category", "value": "Flower" }],
"percentOff": 20,
"maxCap": 25
}
}percentOff and discountValue are mutually exclusive — the rules schema declares .xor("percentOff", "discountValue", "points"), so send exactly one. maxCap caps the dollars taken off a single item.
LINE_ITEM does not work as a typed-in code
LINE_ITEM is intended for auto-apply only. Set isAutoApply: true. The guard that was meant to reject a customer typing a LINE_ITEM code compares discountType against the enum value ("line_item") while creation stores the enum key ("LINE_ITEM"), so it never fires — the code will silently apply instead of being rejected.
Take an amount off the whole cart
FLAT ignores individual items and discounts the cart total.
Config:
json
{
"discountCode": "SAVE10",
"discountType": "FLAT",
"routine": "standard",
"rules": {
"constraints": [{ "restriction": "all", "value": "all" }],
"discountValue": 10,
"minCap": 50
}
}minCap is the minimum cart total required — below it the discount computes to zero. FLAT constraints are the one place the schema pins the values: restriction and value must both be "all". You cannot scope a FLAT discount to a category.
A discountValue larger than the cart is clamped to total - 0.01. A cart never reaches zero through a discount.
Waive delivery charges
Free shipping is not a type. It is a FLAT discount with applicableOnDeliveryCharge: true, which lets the discount consume the delivery line rather than only the subtotal.
Config: discountType: "FLAT", applicableOnDeliveryCharge: true, and rules.minCap set to the order threshold that unlocks it.
Reward spending a threshold on a product group
BULK sums the price of the items matching the giveaway constraint and applies the discount only once that sum clears minCap.
Config:
json
{
"discountCode": "FLOWER150",
"discountType": "BULK",
"routine": "standard",
"rules": {
"constraints": {
"bag": { "include": [{ "restriction": "category", "value": "Flower" }] },
"giveaway": { "include": [{ "restriction": "category", "value": "Flower" }] }
},
"percentOff": 10,
"minCap": 150
}
}minCap here is dollars, not units
BULK compares minCap against the summed price of the matching items. It cannot express "10% off when you buy 6 or more" — there is no quantity threshold in BULK. For quantity-driven deals use BOGO or BOGF, where minCap is a count.
Give away items when enough qualifying items are in the cart
BOGO counts the items matching constraints.bag, counts the items matching constraints.giveaway, and works out how many times the deal fits.
Config:
json
{
"discountCode": "PREROLL21",
"discountType": "BOGO",
"routine": "standard",
"rules": {
"constraints": {
"bag": { "include": [{ "restriction": "category", "value": "Pre-Rolls" }] },
"giveaway": { "include": [{ "restriction": "category", "value": "Pre-Rolls" }] }
},
"minCap": 2,
"maxCap": 1,
"bagLimit": 3
}
}minCap— how many to buymaxCap— how many to givebagLimit— how many times the deal may repeat in one order
The giveaway list is sorted cheapest-first, so the customer gives away their lowest unit-price qualifying items. Given items are repriced to minimumPricePerQuantity (default 0.01), not to zero.
BOGO has no "get 50% off" setting. A percentage on the second item is BOGF.
Give away one specific product
BOGOS is BOGO with a fixed giveaway SKU. constraints.giveaway.include is required, and its first entry's value is read as a productId.
Config: as BOGO, plus constraints.giveaway.include: [{ "restriction": "productId", "value": "8842" }]. Here minCap is the buy quantity and maxCap is the quantity of the free product pulled from hub inventory. The product must be in stock at the hub and must pass the customer's loyalty tier restriction, or the discount resolves to nothing.
Sell a fixed-size bundle at a set price
BOGF takes a bag of an exact quantity and prices it as a unit.
Config:
json
{
"discountCode": "PICK4FOR100",
"discountType": "BOGF",
"routine": "standard",
"rules": {
"constraints": {
"bag": { "include": [{ "restriction": "category", "value": "Flower" }] },
"giveaway": { "include": [{ "restriction": "all", "value": "all" }] }
},
"minCap": 4,
"exact": 100
}
}exact sets the target price for the bag — the discount becomes the difference between what those items cost and exact. Use percentOff or discountValue instead of exact for a proportional bundle discount; the four are .xor, so send one.
minCapFixed defaults to true
By default the matching quantity must equal minCap exactly. Five items in a "pick 4" bundle means the discount does not apply at all, rather than applying once. Set minCapFixed: false to allow the deal to repeat.
Reward a customer's Nth order
PURCHASE_COUNT counts the customer's orders in COMPLETED or RETURN state and applies only when that count equals orderCount.
Config:
json
{
"discountCode": "THIRDORDER",
"discountType": "PURCHASE_COUNT",
"routine": "standard",
"rules": {
"orderCount": 3,
"constraints": [{ "restriction": "all", "value": "all" }],
"percentOff": 15
}
}This is an equality check, not a threshold
orderCount: 3 fires on the fourth order only (when three completed orders exist) and never again. It is not "3 or more", and it is not a tier ladder — for "buy 3–5 get 10%, buy 6+ get 15%" you need two separate discounts, and neither type supports it directly. It also requires a logged-in customer; anonymous carts skip it.
Restrict a promotion to certain days or hours
Set routine: "recurring" and supply routinePeriod. It is checked at redemption and again when discounts are attached to catalogue and product responses.
Config:
json
{
"routine": "recurring",
"routinePeriod": [
{ "day": 5, "startTime": "16:20", "endTime": "23:59" },
{ "day": 6, "startTime": "16:20", "endTime": "23:59" }
]
}routine is required on create. Use "standard" when you don't want a recurrence — routinePeriod is joi.forbidden() unless routine is "recurring".
Day numbering is off by one, and Sunday is unreachable
The validator accepts day 1–7, but the check compares it against JavaScript's getDay(), which returns 0 for Sunday through 6 for Saturday. So day: 1 is Monday … day: 6 is Saturday, day: 7 never matches anything, and Sunday cannot be expressed. startTime and endTime are parsed in the server's timezone, not the brand's.
Limit how many times a customer can redeem
userLimit on the discount is the per-customer cap for ordinary promotions (default 1). Usage is counted from that customer's orders carrying the code.
For campaign-reward coupons (forCampaignReward: true) the limit comes from the matching user_discounts row instead, which is also where per-customer and per-tier grants live. Tier grants match against the customer's current_level.
There is no total redemption budget. Nothing in the discount model caps aggregate usage across all customers, and nothing auto-deactivates a code when a spend target is hit.
Control what stacks with what
canStack and canProductStack decide whether a discount can join a combination.
The engine sorts discounts into candidate stacks: anything with canStack or canProductStack set is added to the running combinations, anything without starts its own. Every candidate stack is then evaluated in full and the one with the highest totalSavings wins. The customer always gets the best combination available, not the first one matched.
canProductStack additionally decides whether this discount may touch an item another promotion has already discounted (isPromotional).
onInclusive is a tax setting, not an exclusivity setting
Despite the name, onInclusive selects the price basis. With the default false the discount is computed on tax-exclusive prices and taxes are recalculated on the discounted amount. With true it is computed on the tax-inclusive total and the tax breakup is left alone. It has nothing to do with combining promotions.
Let a discount be paid for in points
Any of LINE_ITEM, FLAT, BULK, BOGO, BOGOS, and BOGF accept a points object with the same percentOff / discountValue / minCap / maxCap shape. It is evaluated against the cart total and returned as totalPoints alongside the cash discount.
Point multipliers are not promotions. They are campaign rewards — see Creating Campaigns.
Reference
Discount types
Send the key as discountType. The controller validates against Object.keys(discountTypes) and the rules schema is keyed the same way, so "BOGO" is accepted and "buy_one_get_one" is rejected. The lowercase enum values are legacy and are not what gets stored.
discountType | What it does | Quantity fields | Amount fields |
|---|---|---|---|
LINE_ITEM | % or $ off each matching item | — | percentOff | discountValue, maxCap |
FLAT | % or $ off the cart total | — | percentOff | discountValue, minCap, maxCap |
BULK | % or $ off matching items once their combined price clears minCap | — | minCap (spend), percentOff | discountValue |
PURCHASE_COUNT | Applies on the order where completed-order count equals orderCount | orderCount | percentOff | discountValue, minCap, maxCap |
BOGO | Buy minCap, get maxCap at minimumPricePerQuantity | minCap, maxCap, bagLimit | minimumPricePerQuantity |
BOGOS | Buy minCap, get a specific product | minCap, maxCap, bagLimit | minimumPricePerQuantity |
BOGF | Exact-size bag at a set price or discount | minCap, bagLimit, minCapFixed | exact | percentOff | discountValue |
NEIGHBOUR | Nothing — see below | distance | discountValue |
NEIGHBOUR does not work
NEIGHBOUR is in the enum and has a rules schema, so it validates and saves without error. Neither backend's discount engine has a NEIGHBOUR branch, so it never matches a cart and never discounts anything. Do not offer it. Its distance field is read by nothing.
minCap and maxCap change meaning by type. In LINE_ITEM, FLAT, BULK, and PURCHASE_COUNT they are dollars — a spend floor and a discount ceiling. In BOGO, BOGOS, and BOGF they are item counts.
Constraints
Two shapes exist. LINE_ITEM, FLAT, PURCHASE_COUNT, and NEIGHBOUR take a flat array. BULK, BOGO, BOGOS, and BOGF take { bag: { include, exclude }, giveaway: { include, exclude } } — bag selects what qualifies, giveaway selects what gets discounted.
restriction | value |
|---|---|
all | all — matches everything |
productId | Comma-separated product IDs, or all |
category | Comma-separated category names, or all |
subCategory | Comma-separated sub-category names, or all |
brandName | Comma-separated brand names, or all |
tags | Comma-separated tags; matches any |
| product attribute name | Attribute value, e.g. Strain → Indica, or Size, Type |
Attribute restrictions are matched against the item's attrs at cart-evaluation time. Any string is accepted by the validator, so a typo saves cleanly and simply never matches.
Discount fields
| Field | Default | Effect |
|---|---|---|
discountCode | required | Uppercased and trimmed; must be unique platform-wide |
discountType | required | Enum key — see above |
routine | required | standard or recurring |
routinePeriod | [] | Forbidden unless routine: "recurring" |
startDate / endDate | — | Active window |
userLimit | 1 | Per-customer redemption cap |
isAutoApply | false | Applies without the customer entering a code |
isCustomerFacing | true | Visible to the storefront |
canStack | false | May be combined with other discounts |
canProductStack | false | May discount an already-discounted item |
onInclusive | false | Tax basis — see the warning above |
showInApplicable | true | Listed on products it applies to |
showOnScan | false | Surfaced in the scan flow; also auto-approves the hub relation |
forPopUp | false | Surfaced in the pop-up slot; also auto-approves the hub relation |
forCampaignReward | false | Granted through a loyalty campaign; limits come from user_discounts |
applicableOnDeliveryCharge | false | May reduce the delivery line |
dealText | — | Copy shown alongside the discount |
banners | — | Banner images, with pages, mediaSize (sm/md/lg), and optional watermark |
brands | — | Hub and org scope: "all" or comma-separated brand IDs |
Fields that are accepted but do nothing
Sent without error, stored, read by nothing:
redeemExpiryDate— on the discount payload and in the schema; no code reads itstackPercentOff,stackDiscountValue,stackMinPrice,stackCap— in theLINE_ITEM,FLAT,BULK,PURCHASE_COUNT, andNEIGHBOURrules schemasstackMinCap,stackMaxCap— in theBOGOandBOGOSrules schemasdistance— the onlyNEIGHBOUR-specific field, andNEIGHBOURitself is inert
Stacking is controlled by canStack and canProductStack on the discount. The stack* rule fields are not an alternate configuration; they are dead.
Scope and approval
A discount is created from one of four scopes, which decides the discount_hub_brand_relations rows written for it:
| Created from | Scope |
|---|---|
| Brand | That brand's hub relations |
| Hub | The hub, across brands: "all" or the listed brand IDs |
| Marketplace | Brands of type marketplace, tagged with the marketplaceId |
| Organization | One discount with brandId: null and org_id set, spanning the org's brands |
Each relation carries isApproved (default false) and isEnabled (default true). Customer-facing queries require both, so a brand-created discount stays dark until the hub approves it. Discounts created with showOnScan, forPopUp, or from a hub are written with isApproved: true and go live immediately.
There is no funding split, commission adjustment, promotion budget, or geo-fencing in the discount model. Those are marketplace commercial arrangements, not settings.
Troubleshooting
Nothing happens when the code is applied. Work down in this order: routine is recurring and today's routinePeriod does not match; the customer's userLimit is used up; the discount_hub_brand_relations row is not both approved and enabled; startDate/endDate exclude now; no cart item satisfies constraints; for BOGF, minCapFixed is on and the quantity is not exactly minCap; for PURCHASE_COUNT, the completed-order count is not exactly orderCount.
The engine swallows per-discount errors and moves on, so a failing discount produces no customer-visible error — it just contributes nothing.
A different promotion applied than expected. All valid stacks are evaluated and the highest totalSavings wins. Check whether the code you expected is in a lower-saving stack.
The type is rejected on create. Send the enum key (BOGO), not the value (buy_one_get_one).
A free item is showing at one cent. That is minimumPricePerQuantity, default 0.01. Giveaway items are repriced to it rather than to zero.
Related Guides
- Loyalty Campaigns — point multipliers and campaign-granted coupons
- Product Management — categories, tags, and attributes that constraints match on
- Gift cards and promo codes — how a customer actually redeems one at checkout