Checkout rules and groups

Automatic discounts, customer groups with their own price list and invoice terms, and rules for which shipping rates and payment methods a cart sees.

Written 2026-09-16, discount codes rewritten 2026-09-22. Four kinds of row that change what one cart is charged and what it is offered: customer groups (a shopper's own price list and payment terms), pricing rules (automatic discounts, no code to type), discount codes (the same rules, triggered by a name the shopper types) and checkout rules (which shipping rates and payment methods a cart may see). They are data, not code. One engine evaluates them, and the same evaluation runs in the cart, at the payment page and in simulate_checkout — so the price a shopper sees is the price they pay.

Two things a rule can never do: invent a shipping rate the store did not configure, and take a line below zero — or below its cost, when you record one.

1. The condition grammar

Every rule, of either kind, is gated by the same conditions. Every part you state must hold; a rule with no conditions always applies.

ConditionHolds when
minSubtotalMinorthe cart subtotal — after any group price — is at least this many minor units
minQuantitythe cart holds at least this many units in total
firstOrdertrue: the signed-in shopper has no paid order yet. false: they have
customerGroupthe shopper is in the group with this handle
customerTagthe shopper carries this tag (compared case-insensitively)
destinationCountriesthe ship-to country is one of these ISO 3166-1 alpha-2 codes
destinationPostalCodesthe ship-to postal code starts with one of these prefixes, compared with spaces, hyphens, dots and case removed (SW1 matches sw1a 2aa)
productIdsthe cart holds at least one of these products or variants
collectionHandlesthe cart holds a product in one of these collections
customField{ ownerType, namespace, key, equals } — a custom field with exactly that value sits on a product or variant in the cart, or on the shopper (custom-fields.md)
{ "minSubtotalMinor": 15000, "destinationCountries": ["US", "CA"], "collectionHandles": ["summer"] }

The two destination conditions hold only once the shopper has given a "Ship to" estimate in the cart; a cart without one matches neither. Formahand geocodes nothing, so a delivery radius is written as the postal-code prefixes that ring the shop (shipping.md §9).

A condition that names the shopper — customerGroup, customerTag, firstOrder — only holds for a shopper who is signed in to the store (shopper-accounts.md). An anonymous cart is priced at list, and so is the public read API's answer: group prices never leak to a request that does not carry that shopper's session.

2. Customer groups

A group is a handle, a price list, a minimum order, a tax flag and payment terms.

FieldWhat it does
handlewholesale, trade-gold — what customerGroup conditions name
priceListeither { "<productId or variantId>": priceMinor } per item, or the single key "%" with a negative whole percent off the list price: { "%": -20 }
minOrderMinorthe smallest order the group is meant to place, measured against what the shopper actually pays for the goods — the subtotal after discounts — in the cart's own minor units (¥1500 is 1500, not 15.00)
taxExemptthe group is recorded as tax exempt
termscard (the store's usual checkout) or invoice

A group price replaces the list price; it is not a discount, so it is not capped by the cost floor and it stacks with pricing rules the way a lower list price would. terms: "invoice" makes bank transfer the only payment method for that group: their checkout places the order and shows the store's payment instructions instead of taking a card.

A group's prices are money, so a cart may not simply claim to be in one. The store mints a short-lived, signed proof when it renders the cart of a signed-in shopper; the checkout form carries it and the store verifies it again before pricing. A forged or stale proof simply prices the cart at list.

3. Pricing rules

An automatic discount. Four kinds:

KindvalueWhat comes off
percent1–100that percent off every targeted line
amountminor unitsthat much off the cart, spread across the targeted lines in proportion to their line totals
bxgy—one set is applies.buyQuantity + applies.getQuantity units, and the cheapest getQuantity of each set are free: "buy 2 get 1" needs three in the cart, not two. applies.getProductIds names a separate pool the free units come from; where that pool and the set being counted overlap, the overlapping units are counted once, not twice, so naming the same product in both changes nothing. A code whose free pool sits entirely inside the set it counts is refused when it is written, because it reads as a bargain twice over and is almost never what the merchant meant
free_shipping—every shipping rate for this cart drops to zero

applies says which lines a discount comes off:

{ "target": "all" }
{ "target": "products", "productIds": ["<id>", "<variant id>"] }
{ "target": "collections", "collectionHandles": ["clearance"] }
{ "target": "products", "productIds": ["<id>"], "buyQuantity": 2, "getQuantity": 1 }

startsAt and endsAt are ISO timestamps; a rule outside its window, or with enabled: false, is simply not considered.

Floors

  • Never below zero per line.
  • Never below the line's cost, when the product or variant carries a supplier.cost custom field (a money or number value in minor units). A line already priced at or under cost cannot be discounted at all.
  • Never more than 100% of the cart, which follows from the per-line floor.

Gifts below cost

The cost floor has one exception, and only you can make it: a gift exception says "this rule may give this product away". Set it with set_gift_exception { subject, productId, allowed } (or PUT /api/gift-exceptions, scope settings:write), where subject is rule:<pricing rule id>, code:<discount id> (a template's covers every code made from it) or function:<store function name>. Exactly that subject may then take exactly that product or variant to zero; every other rule still stops at cost. Each exception is an event in your log with who set it, and list_gift_exceptions shows them all. Without one, a 100% rule on a product with a recorded cost takes it down to its cost and no further.

Each line's discount is rounded to a whole number of minor units per unit, rounded down. One integer price per line is what keeps the cart, the payment session and the order in step with nothing to reconcile afterwards; rounding down is the only direction that cannot take off more than the rule says. So a rule may give up to (quantity − 1) minor units less than its face value where the quantities do not divide — an amount rule spends that change on a line that can absorb it — and a budget too small to move one unit's price by a whole minor unit takes nothing at all.

Stacking and priority

priority orders the rules — lower numbers run first — and stackable decides how they combine:

  1. Every matching stackable rule is applied in priority order, each on the price the one before it left.
  2. Every matching non-stackable rule is then tried on its own.
  3. Whichever gives the larger total discount is what the cart gets. A non-stackable rule never combines with anything.

free_shipping rules sit outside that contest: a matching one always applies, because it takes nothing off the lines.

Showing a rule on the storefront

A rule with showOnStorefront: true is an offer the storefront may advertise: the cart JSON (/__fh/cart.json, see section scripts) lists it with how far the cart is from it (remainingMinor, progress, met), so a progress bar or a banner says exactly what the engine will do. It is off by default, so a rule meant for one customer group is never shown to everyone. Your cheapest free-shipping threshold is listed too. A cart page drawn by your own section shows the same list without a script: {% widget "offers" %} and {% widget "progress" %}, or the offers and cart.progress objects (sections).

3a. Discount codes

A code is a pricing rule with a name the shopper types. Same four kinds, same condition grammar, same applies — and our own engine applies it, in the cart and again in the checkout quote. Nothing about a code lives on a payment processor, so a code works with card payments, PayPal and bank transfer alike, the payment session only ever sees amounts that are already discounted, and no second code box can apply it twice.

FieldWhat it does
code3–32 letters and digits, upper case. What the shopper types
kindpercent, amount (with currency), free_shipping, bxgy — as §3
conditionsthe §1 grammar: minimum subtotal or quantity, first order, customer group or tag, destination, products, collections, a custom field
appliesall, named productIds, named collectionHandles; buyQuantity/getQuantity for bxgy
startsAt / expiresAtthe window, ISO timestamps; outside it the code is refused
maxRedemptionsredemptions allowed across the store; null is unlimited
perCustomerLimitredemptions allowed per customer; null is unlimited. Counted against the signed-in shopper and against the address the checkout is for, so a guest checkout is limited too. The address is only ever counted — it never prices a cart, because an unverified address must not buy a customer group's prices
combinabletrue: the code applies after the automatic rules. false (the default): the code replaces every automatic discount on that cart
statusactive, paused (refused, reversible) or ended (refused, final)

A code is ordinary data: PUT /api/discounts (or upsert_discount) with the same code edits it, end_discount ends one, and both counters come back on the list — usageCount (redemptions the store counted, what maxRedemptions is measured against) and timesUsed (paid orders carrying the code).

Codes made from a template

A code marked template: true is never typed itself. It is the parent of single-use codes made from it: one per newsletter sign-up, per order, per referral, per win-back email. Make them with:

  • mint_discount_codes { template, count 1-100, prefix?, expiresInDays?, email? } (MCP), or POST /api/discounts/mint (REST); both need the discounts:issue scope;
  • an app granted Make one-off discount codes;
  • a flow's issue_code action (automations), whose code later actions read as {{ issued.code }};
  • a store function's mint_code action on an order event (store functions).

What holds, whoever makes them:

  • Never more than the template. A code made from a template keeps no pricing of its own: it is always exactly the template's kind, value, conditions and products. Pause or end the template and every code made from it stops at once.
  • Single use, and with email only a checkout with that address can use it.
  • The template's per-customer limit holds across all its codes, so a person holding three codes from one template still gets it as often as the template allows.
  • Its own window is the tighter one: expiresInDays and the template's own end, whichever comes first.
  • Unguessable: 11 random characters (from an alphabet without I, L, O, 0 and 1) after an optional prefix.
  • Capped per day: 1,000 for a token or a person, 500 for an app or a flow, 200 for a store function, 2,000 for the store. A call over a cap makes nothing at all. The day is counted in UTC.

Your own daily limits. Those caps are safety defaults, and they are yours to change. The store's owner or an admin, signed in, or a token with settings:write, sets them with set_code_limits { perStore, perCaller: { function, flow, app, token, member } } (or PUT /api/discounts/limits): each a whole number from 1 to 50,000, and null puts one back to its default. A limit you leave out keeps its value. Every code made is checked against both its caller's limit and the store's, so a caller's limit above the store's is held to the store's. An app cannot change them, because an app is one of the callers they hold. list_discounts shows the limits in force (codeLimits: the limits, the defaults, the ceiling and which you changed), and every change is a code_limits.updated event naming who made it.

Made codes are listed under their template (list_discounts { template }), never beside your own codes, and each one is a discount.created event naming its template and who made it.

https://<your store>/discount/<CODE>?to=/products/mug stores the code for the shopper, exactly as typing it in the cart does, and takes them to that page of your store (anything else goes to your home page). Every code is answered the same way, so the link cannot be used to test codes; the cart says what the box would.

The cart

Every theme shows the same small box: an input and Apply, the applied code as a chip with Remove, and one plain sentence underneath. The code lives in an fh_code cookie beside the cart, and is validated again on every render and again at checkout, so an edited cookie buys nothing.

The shopper seesWhen
That code is not valid.no such code — or this shopper has already spent their allowance of it. One sentence covers both on purpose: a different answer for "you have already used this" would let anyone holding a public code test addresses one at a time and read off which have shopped here. The shopper-facing cart carries the sentence and never the reason
That code is no longer available.paused or ended
That code is not active yet.before startsAt
This code has expired.after expiresAt
That code has been fully redeemed.maxRedemptions reached
That code cannot be used in this currency.an amount code in another currency, or one that names no currency at all
Add the email you are checking out with to use this code.the code carries a per-customer limit and the checkout has named nobody. An unidentifiable checkout cannot be limited, so the reservation itself is refused — not just the quote — rather than the code being handed out without end. The cart does not ask: nothing has been bought there yet
Add $20.00 more to use this code.under conditions.minSubtotalMinor
That code does not apply to the items in your cart.any other condition fails
That code takes nothing off this cart.it matched but had nothing to come off — the cart is then priced exactly as it would be without it
Your cart already has a better offer, so that code was not applied.a code that is not combinable would have replaced automatic discounts worth more than the code. The cart keeps the better offer; a free-shipping code is exempt, because this engine cannot price shipping

Redemptions

A redemption is taken when the order is reserved and given back when the reservation is — an expired session, a failed payment, or an unpaid order the merchant cancels. Counting at the paid event instead would leave the limit unguarded for the minutes a checkout is open, which is exactly when two shoppers race for the last redemption; this way the ceiling is taken first and released on failure, so nobody can exhaust a limited code without paying. A cancelled paid order keeps its redemption: it was a sale.

One redemption is recorded per order — unique on the store, the code and the order — so a retried checkout counts once. The ledger row and the counter move in one batch, so no crash can leave a redemption counted but unrecorded, and both ceilings are enforced inside that write rather than read first and written after: the row only lands while usage_count < max_redemptions and while this shopper is still under per_customer_limit, and the counter follows the row. Two simultaneous reservations therefore can never both take the last one; the loser's order simply does not carry the code. A code with a per-customer limit is refused outright for an order that names neither a customer nor an address, because there would be nobody to hold to the limit — and that refusal happens before anything is written, so such a sale is turned away with a sentence rather than reserved at a price nobody can be held to.

For any of this to work the reservation has to know who it is for, so it carries customerEmail (and customerId when the shopper has an account) from the platform to the store, and the order records them the moment it exists. The store also looks the address up in its own customer list, so an account and the address behind it are one person to a per-customer limit. None of it prices anything: a customer group's prices still need the signed shopper claim the quote verified.

The order records the code in discount_code, the money in discount_minor, and the whole verdict in applied_rules_json.

4. Checkout rules

What a cart may be offered. Five kinds, each gated by the same conditions:

KindtargetEffect
hide_raterateNamesremoves those shipping rates (names compared case-insensitively)
show_rate_onlyrateNameskeeps only those
hide_paymentpaymentProvidersthe cart falls back to the first method the rule did not hide
pickupaddress (optional)adds a zero-cost "Pickup at …" option, first in the list; the label comes from the store's ship-from address unless address overrides it
local_deliveryrateName (default Local delivery), feeMinoradds the merchant's own delivery option at their own fee; gate it with destinationPostalCodes, and with minSubtotalMinor for a minimum order

pickup and local_delivery are the only rules that add anything, and neither can invent a price: pickup always costs nothing, and local delivery costs exactly what the merchant put on the rule. Checkout → Shipping turns both on as one-click cards. If narrowing leaves a cart with no shipping option at all, checkout refuses with "No shipping options are available for this order." rather than quietly offering something the merchant did not mean.

5. What the order records

An order carries applied_rules_json — the total taken off, whether shipping was free, the customer group that priced it, the code that was typed, and each rule's id, name and amount — and discount_minor, the money, written at reservation so the order list, the order detail, the thank-you page and the order email all show the same figure. A settlement that carries no discount of its own never overwrites either. Line prices on the order are the discounted ones, so the subtotal, the payment and the order always agree.

6. The API

/api/pricing-rules, /api/checkout-rules and /api/customer-groups on the platform, same as every other route: a browser session, or Authorization: Bearer fh_<mode>_… / fh_test_… for the environment the token names.

CallBody or queryAnswers
GET /api/pricing-rules—{ rules: [...] }, in the order they run
PUT /api/pricing-rulesthe rule (omit id to create){ rule }
DELETE /api/pricing-rules?id=—{ success: true }
GET /api/checkout-rules—{ rules: [...] }
PUT /api/checkout-rulesthe rule{ rule }
DELETE /api/checkout-rules?id=—{ success: true }
GET /api/discounts—{ discounts: [...] } with both counts
PUT /api/discountsthe code (omit id to create, or send the same code){ discount }
DELETE /api/discounts?id=—{ success: true }; the code ends
POST /api/discounts/mint{ template, count?, prefix?, expiresInDays?, email? }{ template, codes: [{ id, code, expiresAt }] }; scope discounts:issue
GET /api/gift-exceptions—{ giftExceptions: [...] }
PUT /api/gift-exceptions{ subject, productId, allowed }{ giftExceptions }
GET /api/customer-groups—{ groups: [{ …, members }] }
PUT /api/customer-groupsthe group{ group }
DELETE /api/customer-groups?id=—{ success: true }; its members simply lose the group
POST /api/customer-groups{ customerId, groupHandle | null }{ success: true }

Scopes: settings:read / settings:write for the two rule routes. Groups are customer data, so reading them takes customers:read, writing a group takes settings:write, and moving a customer between groups takes customers:write as well.

curl -sS -X PUT https://formahand.com/api/pricing-rules \
  -H "Authorization: Bearer fh_<mode>_…" -H "Content-Type: application/json" \
  -d '{"name":"10% over $150","kind":"percent","value":10,
       "conditions":{"minSubtotalMinor":15000},
       "applies":{"target":"all"},"priority":10,"stackable":true}'

7. The agent tools

ToolScopeDoes
simulate_checkoutsettings:read{ items, customerEmail?, destinationCountry? } → the priced cart, the rules that would apply, and the shipping and payment options. Read-only: no order, no stock held, no usage counted. Call this before enabling a rule
list_pricing_rulessettings:readevery automatic discount, in the order they run
upsert_pricing_rulesettings:writecreates or replaces one
delete_pricing_rulesettings:write{ id }
list_checkout_rulessettings:readevery shipping and payment rule
upsert_checkout_rulesettings:writecreates or replaces one
delete_checkout_rulesettings:write{ id }
list_discountssettings:readevery code with its conditions, window, limits, status and counts
upsert_discountsettings:writecreates or edits one; the same code edits it
end_discountsettings:write{ id } — ends a code for good (status: "paused" pauses it instead)
mint_discount_codesdiscounts:issuesingle-use codes from a template (§3a "Codes made from a template")
set_gift_exceptionsettings:writelets one rule, code or function give one product away below cost
list_gift_exceptionssettings:readevery gift exception, with who set it
list_customer_groupscustomers:readthe groups with their member counts
upsert_customer_groupsettings:writecreates or replaces one
set_customer_groupcustomers:write{ customerId, groupHandle | null }

Naming customerEmail or customerId in a simulation also needs customers:read, because the answer says which group that person is in.

8. Events

Every write records pricing_rule.*, checkout_rule.*, customer_group.*, customer_group_membership.*, discount.* (one discount.created per code made from a template, naming the template, the address it is bound to and who made it) or gift_exception.* in the store's log, with the row before and after (events.md). Subscribe to them like any other topic, or react inside the store with a flow (automations.md).

9. Good practice

  • Simulate first. simulate_checkout costs nothing and tells you exactly what a rule does to a real cart. A rule that looks right and prices wrong is the expensive kind.
  • Give every rule a priority you chose. Two rules at the same priority run in creation order, which is not a decision.
  • **Record supplier.cost.** It is the one floor that protects a margin when several rules stack, and it costs one custom field per product.
  • **Use stackable: false for the headline offer.** A launch discount that must not combine with everything else is exactly what a non-stackable rule is for.
  • **Leave combinable off on a code** unless you mean it. A combinable code lands on top of every automatic discount the cart already earned, which is usually more than you meant to give away.
  • Pause before you end. status: "paused" stops a code and can be undone; ended cannot.
  • Keep group price lists small. A "%" entry covers a whole catalog; per-item prices are for the handful of items where the percentage is wrong.

See also

  • agents.md — the ten tools here, their scopes, and simulate_checkout as the step before enabling anything.
  • custom-fields.md — the customField condition and the supplier.cost floor.
  • shopper-accounts.md — group prices only reach a signed-in shopper's cart.
  • product-kinds.md — a digital or service cart has no shipping rate for a rule to hide.
  • headless.md — a front end of your own never sends prices; these rules run at checkout either way.
  • analytics.md — measure a rule's effect on revenue and average order before and after.