A discount is one sf_discount record. With a code, the shopper types it in the checkout's Promo Code box. With no code it is automatic: the checkout applies it to every cart that qualifies. The same record runs online and at the till (POS).
A discount is money off (or free shipping, or points) at checkout. A promotion is something else: a sign-up list that hands each subscriber a discount code. For that, see Promotions and the sign-up page.
The checkout never adds up discounts in the browser. It sends the cart and the code to POST storefront/discounts/calculate, and the server answers with the lines, the discounts and the total. That call is how you test a discount too (step 4).
Managing discounts is staff only. Listing, reading, creating, editing, activating, deactivating, deleting and the usage calls (GET, POST, PUT, DELETE storefront/discounts…) need someone signed in to the business: Studio, or you acting for the signed-in person. The site and its visitors get 403 This needs someone signed in to the business (tested through a site's /api). Shoppers only check a code (validate, calculate, apply), get the verdict and the saving (never the stored record), and see the codes open to everyone in GET storefront/discounts/promotions/active (discount.controller.ts; discount.service.ts publicResult, listPublicPromotions).
Ask first
- What is the offer? % off, an amount off, buy X get Y, free shipping, or points / store credit given when the order is paid.
- Code or automatic? A code the shopper types (and its text), or applied to every qualifying cart.
- On what? The whole cart, some products, or some categories. Products that must never be discounted.
- Who may use it? Everyone, some customer groups, named customers (by email), first-time customers only.
- Conditions: a minimum spend, a minimum number of items, products that must be in the cart.
- When: start and end date; days of the week or hours of the day.
- How often: total uses, uses per customer.
- Does it combine with other offers running at the same time?
Steps
1. Storefront › Discounts: see what exists
Open Storefront › Discounts (/discount). Tabs: Dashboard, Discounts (?tab=list), Manage. One discount opens with ?openId=<sk>.
call_appengine GET storefront/discounts lists them, highest priority first. It takes status, hasCode (true codes, false automatic), search (name, code, description) and page / pageSize. GET storefront/discounts/stats gives total, active, totalUsage, byStatus, byType. An active discount whose end date has passed counts as expired. GET storefront/discounts/<code or sk> reads one.
Look for an existing offer that already does the job, and for automatic discounts. Those apply to every cart, so a new code may clash with one (see Stacking).
Hands on: the codes in use (a new code must be unique) and any active automatic discounts. Check: the list on screen matches the call.
2. Storefront › Discounts: the discount, as a draft
Create it with call_appengine POST storefront/discounts. Leave status out: a new discount is a draft, which no shopper can use.
{
"name": "Spring 10",
"code": "SPRING10",
"type": "percent",
"value": 10,
"applyTo": "cart",
"minCartValue": 50,
"endDate": "2026-05-31T23:59:59.000Z",
"usageLimitPerCustomer": 1
}It answers with the saved record. The service saves code in capitals and refuses a code that exists already: Discount code SPRING10 already exists. Codes match the shopper's typing without regard to case. Leave code out for an automatic discount.
The offer
| Field | What it does |
|---|---|
name | What the shopper reads beside the saving ("Spring 10", "Free shipping"). Also the staff name. |
description | Staff notes. Not shown at checkout. |
code | The code. Letters, digits, - and _ (Studio checks this; the API doesn't). Empty means automatic. PUT storefront/discounts/… never changes it. |
type | percent, fixed, buy_x_get_y, free_shipping, reward_points, store_credit (below), and free_deposit, free_delivery, waive_fees (rentals only). |
value | The % for percent; the amount for fixed; the points or the credit for reward_points / store_credit. |
maxDiscount | For percent: the saving never goes above this. |
buyXGetY | { buyQty, getQty, getPercent }. Defaults 2, 1, 100 (Buy 2 Get 1 free). |
freeShippingScope | For an automatic free_shipping: cheapest (default) frees the cheapest shipping option, any frees every option. API only. |
priority | Higher is applied first. Decides which automatic discount wins (see Stacking). |
stackable | true lets it combine with other money-off offers. Default false. |
status | draft (default), active, inactive. Only active applies. |
The types, as the checkout prices them (all tested):
| Type | The saving |
|---|---|
percent | value% of what it applies to, rounded to the cent, capped by maxDiscount. 10% of a $50 cart is $5. 50% capped at 7 is $7. |
fixed | value off what it applies to, never more than that. $15 off a $10 product gives $10. Once per cart, not per item. |
buy_x_get_y | For every buyQty + getQty items in scope, the cheapest getQty are getPercent% off. Three $20 items on Buy 2 Get 1 give $20. Quantities from different products in scope add up. |
free_shipping | Shipping is free (see How the checkout applies discounts). |
reward_points / store_credit | Nothing off. The checkout shows "You'll get 500 points" / "You'll get $5.00 store credit", and the buyer's wallet gets it once the order is paid. |
What it applies to (applyTo):
applyTo | Lines it covers | Also set |
|---|---|---|
cart (default) | every line, less excludeProducts | |
products | only these products (or their variants) | products: SKUs |
categories | only products in these categories | categories: category names, read from the product record |
rentals, rental_fees | rental lines, rental fee lines | rentalItems, waiveFees |
excludeProducts (SKUs) are never discounted, whatever the scope. The saving is shared out over the covered lines in proportion to their value, and those lines' tax is charged on what is left. On products and categories, a percent or fixed code that covers nothing in the cart is refused (This discount does not apply to the items in your cart).
Who and when
| Field | Rule |
|---|---|
customerGroups | Only customers in one of these groups (group names, as in the customer's groups). Nobody signed in counts as in no group, so a guest is refused. |
excludeCustomerGroups | Customers in these groups are refused. A guest isn't checked. |
customers / excludeCustomers | Emails allowed / refused. An allowed customer must be signed in: a guest who types that email gets Sign in to use this discount. excludeCustomers is checked against the signed-in or typed email. |
firstOrderOnly | Only an email with no orders yet. Needs an email. |
minCartValue | The cart subtotal (products and rentals, before any discount) at or above this. |
minCartItems | At least this many items (quantities added). |
requireProducts | Every one of these SKUs must be in the cart. |
startDate / endDate | ISO date-times. Before the start or after the end it is refused. |
daysOfWeek | [0..6], 0 = Sunday (names like "tue" are accepted and saved as numbers). Empty = every day. |
activeFromTime / activeToTime | "HH:MM", 24-hour. A "to" before the "from" runs past midnight and counts as the day it started. A bad time is refused: From time must be HH:MM (24-hour), e.g. 15:00. |
usageLimit | Total uses. Each placed order that used it counts one. |
usageLimitPerCustomer | Uses per customer, counted by email (or customer id). Needs an email. |
Days and hours are read on the business's clock: the business profile's time zone online, the location's at a till. Without one, UTC.
For a guest, "first order only" and "one per customer" go by the email typed at checkout. Each new email counts as a new customer. "Specific customers" and customerGroups need the customer signed in (discount.service.ts validateDiscount). For a strict one-per-person offer, limit it to a customer group.
On screen. Create Discount opens a form with the same fields and a When it applies box for days and hours. It saves through the generic record save, not the call above, so:
- it does not refuse a code that already exists, and it keeps the code as typed. With two records on one code, checkout uses whichever the database returns first. Tested: a second record on an existing 10% code, at 90%, beat the original.
- its Delete removes a discount even after orders used it.
DELETE storefront/discounts/<code>refuses that:Cannot delete a discount that has been used. freeShippingScopeisn't on the form.
So create and delete with the calls; use the screen to read and edit.
Hands on: the discount's code (or, for an automatic one, its sk).
Check: GET storefront/discounts/<code> returns it as draft, with the fields as meant. Open it at /discount?tab=list&openId=<sk>.
3. Storefront › Discounts: switch it on
A draft is refused at checkout (This discount is not available yet), so the saving can only be seen once the discount is active.
Stop: an active code works for anyone who has it. An active automatic discount changes the price of every qualifying cart on the live site at once. Show the owner the offer in plain words ("10% off orders over $50, once per customer, until 31 May") and wait for yes.
call_appengine PUT storefront/discounts/<code or sk>/activate. …/deactivate turns it off (inactive). On screen: the row's menu, Activate / Deactivate. Other changes: PUT storefront/discounts/<code or sk> with only the fields to change (code and usageCount are ignored).
Hands on: the active discount.
Check: GET storefront/discounts/<code> reads status: "active".
4. Try it with the checkout's own quote
Straight after activating, quote a real basket with the call the checkout makes. If anything is wrong, deactivate at once, fix it and switch it on again. POST storefront/discounts/calculate needs no sign-in on the site's /api, or use call_appengine:
{
"productItems": [{ "sku": "<SKU>", "quantity": 2 }, { "sku": "<SKU 2>", "quantity": 1 }],
"couponCode": "spring10",
"email": "[email protected]",
"shippingAddress": { "name": "Test", "street1": "1500 Marilla St", "city": "Dallas", "state": "TX", "zip": "75201", "country": "US" }
}Leave couponCode out to see automatic discounts alone. Prices come from the catalog; never send prices. It answers the whole cart:
| Field | Meaning |
|---|---|
valid, reason, message | The code's verdict. valid: false with the shopper's sentence in message (table below). A refused code never fails the quote. |
discounts[] | Each discount applied: code (as typed; none for automatic), name, amount, type, message ("10% off"), note (points / credit). |
productItems[].discount | Each line's share of the saving. |
subtotal, discount, tax, productShipping, total | The figures the checkout shows and charges. |
freeShipping | true when a free-shipping discount applied. |
Staff pricing for a named customer (to test a group discount): POST storefront/admin/order/quote with the same body plus "customerId": "<customer sk>". It runs the same calculation as that customer. Tested: a guest was refused a group code, a member of the group got it.
Try at least: a basket that qualifies, one just under each minimum, one without the scoped products, and for a group or customer list, a guest and a member.
Check: the first basket's discount and total are what the owner expects, and every refusal reads as the owner would want a shopper to read it. Then, on the site, put the products in the cart, open checkout, type the code in Promo Code and press Apply Coupon. Under Applied Discounts it shows the name, "Code: …" and the saving, and the summary's total matches the quote.
How the checkout applies discounts
In this order, on every quote and again when the order is placed (calculateCart, appengine discount.service.ts 1357–1994):
- Each line is priced: price lists, quantity tiers, member benefits. A group's benefit % set in Customers & Benefits is part of the price, not a discount record.
- Automatic discounts, highest
priorityfirst. The first money-off one applies; another applies only when it and every one already applied arestackable. Free shipping, deposit, delivery, fee and points discounts always apply. - The code (one per order).
- A staff discount, when staff place an order for a customer.
- Tax, on each line less its share of the discounts.
- Shipping. An automatic free shipping is applied inside the shipping quote: the cheapest option (or all, with
freeShippingScope: "any") becomes 0, and the checkout charges the cheapest. A free-shipping code takes the whole shipping charge off the total, thoughproductShippingstill shows the quoted price (tested: shipping 15, total 50 on a $50 cart).
When the order is placed, each discount it used counts one use (usageCount, and the customer's own count). Codes and automatic discounts both count. A code that a promotion handed out is marked claimed for that subscriber. Cancelling or refunding an order doesn't give the use back.
Stacking: what combines with what
Tested with one automatic 10% on a product and codes on a $50 cart:
| Running | Result |
|---|---|
| automatic only | the automatic discount applies; no code needed |
automatic + a money-off code, either not stackable | the automatic discount stays and the code is refused, even when the code saves more: This code can't be combined with the <name> offer, which is already applied |
automatic + money-off code, both stackable | both apply; the code is worked out on the full lines |
| automatic + a free-shipping or points code | both apply, whatever stackable says |
two automatic, the higher-priority one not stackable | only the higher-priority one, even if the other saves more |
two automatic, both stackable | both |
Online, an automatic discount always beats a code that doesn't stack with it. The till behaves differently: it gives the customer whichever saves more. To let a better code win online, make both stackable, or narrow the automatic discount (applyTo, requireProducts) so they don't meet.
What the shopper sees
The checkout shows message under the Promo Code box and in a toast. Branch on reason, not the words.
| Message | reason | Why | Fix |
|---|---|---|---|
Discount code not found | not_found | no discount has that code | check the spelling, or create it |
This discount is not available yet | inactive | still draft | activate it (step 4) |
This discount is no longer available | inactive | inactive | activate it, if meant |
Discount not yet active | not_started | before startDate | |
Discount has expired | expired | after endDate | move the end date |
This discount applies only on Mondays / …only between 15:00 and 18:00 on weekdays | day_of_week / time_of_day | outside the days or hours | |
Discount usage limit reached | usage_limit | usageLimit used up | raise it |
This code is limited per customer: add the customer (with their email) first | customer_required | usageLimitPerCustomer and no email yet | the shopper fills in contact first (the wording is the till's) |
This customer has already used this code | customer_usage_limit | that email used it up | |
Discount not available for your account | customer_group, customer_group_excluded, customer_not_allowed, customer_excluded | not in the group (or a guest), excluded, not on the email list | sign in as a member; check customers emails |
Sign in to use this discount | sign_in_required | a named-customer code, and the shopper typed that customer's email without signing in | the customer signs in |
Email is required to use this discount | email_required | firstOrderOnly and no email yet | |
Only for first-time customers | first_order_only | that email has ordered before | |
Minimum cart value of $50 required | min_cart_value | under minCartValue | |
Minimum 5 items required | min_cart_items | under minCartItems | |
Required products not in cart | required_products | a requireProducts SKU is missing | |
This discount does not apply to the items in your cart | no_applicable_items | scoped to products or categories not in the cart | |
This code can't be combined with the <name> offer, which is already applied | not_combinable | an automatic money-off discount applied and they don't both stack | see Stacking |
A Buy X Get Y code with too few items is accepted with no saving: valid: true, nothing in discounts, and the checkout says Coupon "…" applied! (tested with 2 items on Buy 2 Get 1). Tell the owner to name the quantity in the offer.
When it's done
Tell the person:
- the code (or "automatic"), what it gives and on what, in plain words;
- who can use it, when, and how many times;
- the quotes tried: basket, saving, total;
- whether it is on, and what it stacks with.
Open it in Storefront › Discounts (/discount?tab=list&openId=<sk>).
What it can't do yet
- Studio's form allows duplicate codes and deletes used discounts (step 2).
- Online, the better deal doesn't win. An automatic money-off discount always beats a non-stacking code (see Stacking). The "best of the two" code in
calculateCartnever runs, because automatic discounts are no longer priced into the lines there. - No coupon box on custom pages.
window.appmint.storefront.applyCoupon/calculateDiscountsonly quote. The code reaches an order only through the built-in checkout.promotionPricecalls a route that doesn't exist (404). - The checkout's list of "available promotions" is always empty. It keeps rows marked
autoApply, whichpromotions/activenever sends. Only discounts actually applied show. - Uses aren't given back when an order is cancelled or refunded;
POST storefront/discounts/<code>/reversetakes one back by hand. Two online orders finishing together can both take the last use of ausageLimit. - One code per order.
Don'ts
- Don't switch on an automatic discount without a yes: it changes every qualifying cart at once.
- Don't create or delete discounts on the Studio form when the calls are available; it skips the duplicate and used checks.
- Don't promise that "first order only" or "one per customer" holds for guests; use a customer group for that.
- Don't put a scoped automatic discount and a better non-stacking code side by side and expect the code to win.
- Don't delete a discount orders used; deactivate it.
- Don't send prices in the quote; send SKUs and quantities.