Discount codes
How discount codes are scoped, the three discount shapes and which of them the admin form can actually create, validity windows, usage limits, and where a discount comes out of your margin.
Discount codes cut the price of one product. That is the first thing to internalise, because it is not how most stores work: a code is created against a product id, and it is rejected on every other product in your catalogue.
There is no store-wide code, no category code, no cart-total threshold and no stacking.
Creating a code
Admin → E-commerce → Sales → Discounts (/admin/ecommerce/discount).
| Field | Rules |
|---|---|
productId |
Required. The one product this code applies to |
code |
Required, and unique across the entire store |
type |
PERCENTAGE (default), FIXED or FREE_SHIPPING |
percentage |
0–100. Only meaningful for PERCENTAGE |
amount |
Cannot be negative. Only meaningful for FIXED |
maxUses |
At least 1. Leave empty for unlimited |
validFrom |
Optional. Empty means live immediately |
validUntil |
Required, and must be in the future when you create it |
status |
Active/inactive |
The storefront normalises whatever the customer types to uppercase before it
looks the code up. A code saved as summer10 is therefore unreachable — the
lookup asks for SUMMER10 and finds nothing, and the customer is told the code
is invalid. There is no error at creation time and nothing in the admin panel
flags it.
Type every code in capitals.
The create and edit forms show the product, code, percentage, valid-until date
and status. type, amount, maxUses and validFrom are real, populated
columns that the API accepts and the checkout honours, but they have no form
field — they appear only in the read-only view panel.
In practice that means the admin screens create percentage discounts. Fixed
amounts, free shipping, usage caps and scheduled start dates have to be set
through POST /api/admin/ecommerce/discount (the request body accepts all of
them and requires code, type, validUntil, productId and status).
The three shapes
| Type | Effect at checkout |
|---|---|
PERCENTAGE |
Subtotal is reduced by percentage% |
FIXED |
Subtotal is reduced by amount, capped at the subtotal so it can never go negative |
FREE_SHIPPING |
The checkout's shipping fee becomes zero. The subtotal is untouched |
A FIXED discount larger than the line is clamped, not carried over — there is
no store credit.
FREE_SHIPPING waives the whole checkout's shipping charge, which is charged
once regardless of how many products are in the cart. On a digital-only cart it
does nothing, because no shipping was charged.
Validity, limits and one-per-customer
Four independent checks stand between a code and a discount:
-
Active.
statusmust be true. -
In window.
validUntilmust not have passed.validFrom, if set, must have arrived. -
Under its cap. If
maxUsesis set, the number of customers who have consumed it must be below that number. The count is taken inside the checkout transaction with the discount row locked, so two simultaneous checkouts cannot both take the last use. -
Not already used by this customer. One use per customer, permanently. There is no per-customer allowance above one.
A single checkout also refuses to apply the same discount to two lines, so a customer cannot split a cart to double a code.
The code-validation endpoint the storefront calls rejects a code whose
validFrom is in the future. The checkout's own re-check looks at status,
product and validUntil only. Through the normal storefront flow a scheduled
code is correctly refused; treat validFrom as a display-and-validation rule
rather than a hard lock, and use status if you need a code to be genuinely
unusable.
What the customer experiences
- They enter the code on the cart or checkout page.
- The storefront calls the validation endpoint, which is rate-limited and does not consume the code. It returns the type, the value and whether it is valid, with a specific message when it is not — expired, not yet active, already used, or limit reached.
- The discount is applied to the quoted total.
- At checkout the same checks run again inside the money transaction. The usage record is written there, and only there.
The two-phase design means a customer who validates a code and abandons the cart has not burnt it.
Where the money comes from
The discount comes out of your revenue, not out of tax or shipping:
- The buyer is debited
subtotal − discount + shipping + tax. - Tax is calculated on the discounted subtotal, so a discount reduces the tax charged too.
- The platform wallet is credited the discounted subtotal as store revenue.
- Shipping and tax are credited separately as a pass-through.
Refunds follow the same shape. Cancelling a discounted order returns the buyer exactly what left their wallet, and reverses exactly what was credited to you.
Cancelling an order refunds the buyer and restores stock. It does not clear the usage record, so the customer cannot use that code again on a replacement order. Issue a new code if you need them to re-buy at the same price.
Reading a discount row
The list shows the code, its product, the percentage, the valid-until date and
the status. That is not enough to understand a FIXED or FREE_SHIPPING
code — the percentage column reads 0 and the actual rule is invisible.
Open the view panel. It leads with the rule itself, stated in the terms of the
discount's own type, plus the window it is live in, whether that window has
already closed, and the usage limit. A discount can be status: true and long
expired; the status badge cannot tell you that and the panel can.
Practical notes
- One code per product, or many. A product can carry several codes. Nothing
stops you creating
LAUNCH20andVIP30against the same item; the customer applies one. - Codes are global identifiers. Uniqueness is across the whole store, so
you cannot reuse
SUMMERon two products. Name themSUMMER-<product>if you run parallel promotions. - Deactivate rather than delete. Deleting a discount cascades to its usage records; deactivating leaves the audit trail intact and stops the code dead.
- You cannot backdate.
validUntilis validated as a future date at creation, so a code cannot be created already expired.
Next: Store settings or the API and permissions reference.