# Promotions, Fees & Discounts This guide covers how discounts and fees work in the Tote Online Ordering API, including promo codes, automatic discounts, fee categories, minimum order enforcement, customer identity for member pricing, and handling discount changes during checkout. By the end, you will understand every discount type, how fees are structured and displayed, how to apply and remove promo codes, how customer identity unlocks member pricing, and how to recover gracefully when prices change between calculation and checkout. ## How Discounts Work All discounts appear as `DiscountLineItem` objects in the `PriceCalculation` response. Discounts can be: - **Cart-level** -- in the top-level `discounts` array (e.g., "10% off your order") - **Item-level** -- in each line item's `discounts` array (e.g., "$1 off this coffee") Each discount has a `source` indicating where it came from and an `application_scope` indicating when it is applied in the pricing formula. ### Source-Scope Matrix All current discount sources use `PRE_TAX` scope, meaning they reduce the taxable amount before tax calculation. | Discount Source | Application Scope | When Applied | Example | | --- | --- | --- | --- | | `AUTOMATIC` | `PRE_TAX` | During `/calculate` | Happy Hour 10% Off | | `PROMO_CODE` | `PRE_TAX` | After code applied | SUMMER25 -- $2 off subs | | `LOYALTY_REWARD` | `PRE_TAX` | During `/calculate` | Member 5% discount | | `MANUAL` | `PRE_TAX` | POS staff override | Manager comp | > **Note:** All current discount sources use `PRE_TAX` scope, reducing the taxable amount before tax calculation. See the [Loyalty-as-Discount](#loyalty-as-discount-pre-tax-discounts) section for a worked example showing the tax savings. ## Promo Codes Promo codes follow a four-step lifecycle: validate (optional), apply, see the discount in price calculation, and remove if needed. ### Validate (optional) Before applying a code, you can check whether it is valid for the current cart. This is useful for inline validation as the user types. ``` GET /carts/{cart_id}/promo-codes/validate?code=SUMMER25 ``` **Response (200 OK):** ```json { "code": "SUMMER25", "valid": true, "discount_preview": { "estimated_discount": { "amount": 200, "currency": "USD" }, "description": "$2 off your order", "applicable_items": [] }, "rejection_reason": null, "rejection_message": null } ``` An empty `applicable_items` array means the discount applies to the entire cart (cart-level discount). > **Note:** The discount preview is an estimate. The actual discount is computed when the code is applied and prices are calculated. ### Apply Apply a promo code to the cart. The code is case-insensitive and always returned as uppercase. ``` POST /carts/{cart_id}/promo-codes ``` ```json { "code": "summer25" } ``` **Response (200 OK):** The updated Cart with the promo code in the `promo_codes` array. ```json { "id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab", "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d", "status": "ACTIVE", "items": ["..."], "promo_codes": [ { "code": "SUMMER25", "status": "ACTIVE", "discount_preview": { "estimated_discount": { "amount": 200, "currency": "USD" }, "description": "$2 off your order" }, "applied_at": "2026-02-01T14:30:00Z" } ], "subtotal": { "amount": 1000, "currency": "USD" }, "total_tax": { "amount": 80, "currency": "USD" }, "total_discount": { "amount": 200, "currency": "USD" }, "total": { "amount": 880, "currency": "USD" }, "created_at": "2026-02-01T14:00:00Z", "updated_at": "2026-02-01T14:30:00Z" } ``` > **One code at a time.** Only one promo code can be active on a cart. Attempting to apply a second code returns a `409 Conflict` with `rejection_reason: ALREADY_APPLIED`. Remove the current code first, then apply the new one. ### See Discount in PriceCalculation After applying a promo code, call `/calculate` to see the full price breakdown including the promo discount. ``` POST /carts/{cart_id}/calculate ``` The promo discount appears as a `DiscountLineItem` in the `discounts` array: ```json { "cart_id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab", "currency": "USD", "line_items": [ { "cart_item_id": "d4e5f6a7-b8c9-0123-4567-890abcdef012", "menu_item_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Coffee", "quantity": 1, "base_price": { "amount": 1000, "currency": "USD" }, "modifier_total": { "amount": 0, "currency": "USD" }, "discounts": [], "item_subtotal": { "amount": 1000, "currency": "USD" }, "item_tax": { "amount": 80, "currency": "USD" }, "item_total": { "amount": 1080, "currency": "USD" } } ], "discounts": [ { "id": "disc-001", "name": "$2 Off Order", "type": "FIXED", "value": null, "amount": { "amount": 200, "currency": "USD" }, "source": "PROMO_CODE", "application_scope": "PRE_TAX" } ], "promo_codes": [ { "code": "SUMMER25", "status": "ACTIVE", "discount_preview": { "estimated_discount": { "amount": 200, "currency": "USD" }, "description": "$2 off your order" }, "applied_at": "2026-02-01T14:30:00Z" } ], "member_pricing_applied": false, "fees": [], "subtotal": { "amount": 1000, "currency": "USD" }, "total_tax": { "amount": 80, "currency": "USD" }, "total_discount": { "amount": 200, "currency": "USD" }, "total_fees": { "amount": 0, "currency": "USD" }, "taxable_amount": { "amount": 800, "currency": "USD" }, "total": { "amount": 880, "currency": "USD" }, "calculated_at": "2026-02-01T14:30:05Z" } ``` Notice that `taxable_amount` is 800 ($8.00), not 1000 ($10.00). The $2.00 pre-tax discount reduced the taxable amount, so tax is calculated on $8.00. ### Remove Remove a promo code from the cart. ``` DELETE /carts/{cart_id}/promo-codes/SUMMER25 ``` **Response (200 OK):** The updated Cart with the promo code removed and totals recalculated. ### Error Handling When a promo code cannot be applied, the validation response (or the apply endpoint) returns a `rejection_reason`. Use this to show helpful messages to the customer. | Rejection Reason | Description | Suggested UX | | --- | --- | --- | | `INVALID_CODE` | Code not recognized | "This code is not valid" | | `EXPIRED` | Past expiration date | "This code has expired" | | `ALREADY_USED` | Single-use, already redeemed | "This code has already been used" | | `MINIMUM_NOT_MET` | Cart below minimum amount | "Add $X more to use this code" | | `NOT_APPLICABLE` | No eligible items in cart | "This code doesn't apply to items in your cart" | | `ALREADY_APPLIED` | Another code already active | "Remove your current code first" | > **UX tip:** Show a promo code input field at checkout, similar to DoorDash or Uber Eats. Validate inline as the user types or on submit. Show the discount preview before the user confirms. This gives immediate feedback and avoids surprises at the final total. ## Automatic Discounts Automatic discounts are applied by the server during `/calculate` based on rules configured in the Tote dashboard. Partners cannot apply or remove automatic discounts through the API -- they are fully server-controlled. ### Trigger Categories | Category | Example | How It Works | | --- | --- | --- | | **Time-based** | Happy hour pricing, weekend specials | Server checks current time against configured windows | | **Spend thresholds** | "Spend $25, get $5 off" | Server evaluates cart subtotal against threshold | | **Item combos** | "Buy 2 coffees, get 1 free" | Server detects qualifying item combinations | | **Location-specific** | Store grand opening discounts | Discount tied to a specific location ID | Automatic discounts appear in the `PriceCalculation.discounts` array with `source: AUTOMATIC`: ```json { "id": "disc-auto-001", "name": "Happy Hour 10% Off", "type": "PERCENTAGE", "value": "10.00", "amount": { "amount": 100, "currency": "USD" }, "source": "AUTOMATIC", "application_scope": "PRE_TAX" } ``` Your integration's job is to display automatic discounts to the customer with their name and amount. Do not try to remove automatic discounts -- there is no endpoint for this. > **Non-discountable items:** Items with `non_discountable: true` on the `MenuItem` are excluded from all automatic discount calculations. See the [C-Store Features guide](/online-ordering/guides/10-cstore-features) for more on item-level flags. ## Fees & Minimum Order Enforcement Fees are separate from item pricing and discounts. They appear in the `fees` array on `PriceCalculation`, `Cart`, and `Order` responses. Each fee is a `FeeLineItem` with a `fee_type` categorizing the charge, a `name` and `label` for display, an `amount` in cents, and a `taxable` flag indicating whether tax applies. ### Fee Categories | Fee Type | Description | Example | | --- | --- | --- | | `DELIVERY` | Fee for delivering the order to the customer | "Delivery Fee: $3.99" | | `SERVICE` | Platform or convenience service fee | "Service Fee: $1.50" | | `BAG` | Per-bag charge required by local regulations | "Bag Fee: $0.10" | | `SMALL_ORDER` | Surcharge when cart subtotal is below the location's minimum | "Small Order Fee: $2.00" | | `OTHER` | Fees that do not fit the above categories | "Processing Fee: $0.50" | > **Tolerant Reader:** Treat `fee_type` as an open enum. New fee types may be added in future API versions. If your integration encounters an unrecognized fee type, display it using the `name` and `amount` fields. ### Minimum Order Enforcement Each location MAY define minimum order amounts per handoff mode. These are available on the Location resource in the `minimum_order_amounts` field, keyed by handoff mode (`delivery`, `pickup`, `curbside`, `kiosk`). If the cart subtotal is below the minimum for the selected handoff mode, the server automatically applies a `SMALL_ORDER` fee covering the shortfall. For example, if the delivery minimum is $15.00 and the cart subtotal is $12.00, a `SMALL_ORDER` fee of $3.00 is applied. Partners do **not** need to enforce minimums client-side. Display whatever fees the server returns -- the server handles all minimum order logic. You can optionally read `minimum_order_amounts` from the Location resource to show the minimum before the customer starts shopping. See the [Cart Lifecycle guide](/online-ordering/guides/04-cart-lifecycle) for how fees appear in the cart summary response. ### Displaying Fees Render each fee from the `fees` array as a separate line item in your order summary: - Use the `label` field for compact summary views (e.g., "Delivery") - Use the `name` field for detailed receipt views (e.g., "Delivery Fee") - The `taxable` boolean indicates whether tax applies to this fee -- the server handles the tax calculation, so you do not need to compute it - Fee amounts are always positive (they add to the total) ``` Subtotal: $12.00 Delivery Fee: $3.99 Small Order Fee: $3.00 Tax: $1.50 Discount: -$2.00 ───────────────────────── Total: $18.49 ``` ### Fees in the Price Formula Fees are added after discounts in the pricing formula: ``` total = subtotal + total_tax + total_fees - total_discount ``` - `total_fees` is the sum of all fee amounts in the `fees` array - Taxable fees are included in `taxable_amount` for tax calculation -- the server determines which fees are taxable based on local rules - A `SMALL_ORDER` fee may or may not be taxable depending on local tax regulations > **Note:** Fees do not interact with discounts. A promo code does not reduce fees, and fees do not affect discount eligibility. They are independent adjustments to the total. ## Customer Identity & Member Pricing Setting a `customer_id` on the cart enables member-specific pricing. This is how loyalty members see their discounted prices. ### Setting Customer Identity You can set `customer_id` at cart creation: ```json POST /carts { "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d", "customer_id": "CUST-12345" } ``` Or update it later via PATCH (the "login-after-browsing" flow, where a customer adds items first and logs in at checkout): ``` PATCH /carts/{cart_id} ``` ```json { "customer_id": "CUST-12345" } ``` **Response (200 OK):** The updated Cart with `customer_id` set. ### How Member Pricing Works When `customer_id` is set, the server applies member-specific pricing on the next call to `/calculate`. The `member_pricing_applied` field in the `PriceCalculation` response indicates whether the server recognized the customer and applied member prices. ```json { "member_pricing_applied": true, "discounts": [ { "id": "disc-loyalty-001", "name": "Member 5% Discount", "type": "PERCENTAGE", "value": "5.00", "amount": { "amount": 50, "currency": "USD" }, "source": "LOYALTY_REWARD", "application_scope": "PRE_TAX" } ] } ``` Use `member_pricing_applied` to show a badge or message in your UI (e.g., "Member pricing applied"). ### Reverting to Anonymous Pricing Set `customer_id` to `null` to revert to anonymous pricing: ```json PATCH /carts/{cart_id} { "customer_id": null } ``` > **Important:** After changing `customer_id`, always re-calculate prices. Previous pricing may be stale because member discounts are only evaluated during `/calculate`. ### Order History Retrieve orders for a specific customer: ``` GET /orders?customer_id=CUST-12345 ``` The `customer_id` filter is an exact, case-sensitive match. ## Loyalty-as-Discount (Pre-Tax Discounts) "Loyalty-as-discount" is the model used by Tote: loyalty rewards are applied as pre-tax discounts during `/calculate`. Pre-tax discounts reduce the taxable amount, which saves the customer money on tax compared to applying the same dollar amount after tax. ### Worked Example Consider a single item with a $2 promo code discount at 10% tax: ``` Item: Coffee $10.00 Promo: "SAVE2" ($2 off) -$2.00 source: PROMO_CODE application_scope: PRE_TAX ------ Subtotal (after discount): $8.00 Tax (10% of $8.00): $0.80 ------ Total: $8.80 Without the pre-tax discount, the total would be: $10.00 + $1.00 tax - $2.00 = $9.00 Pre-tax discount saves the customer $0.20 in tax. ``` ### PriceCalculation Response Here is the full `PriceCalculation` JSON for the worked example above: ```json { "cart_id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab", "currency": "USD", "line_items": [ { "cart_item_id": "d4e5f6a7-b8c9-0123-4567-890abcdef012", "menu_item_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Coffee", "quantity": 1, "base_price": { "amount": 1000, "currency": "USD" }, "modifier_total": { "amount": 0, "currency": "USD" }, "discounts": [], "item_subtotal": { "amount": 1000, "currency": "USD" }, "item_tax": { "amount": 80, "currency": "USD" }, "item_total": { "amount": 1080, "currency": "USD" } } ], "discounts": [ { "id": "disc-promo-001", "name": "SAVE2 - $2 Off", "type": "FIXED", "value": null, "amount": { "amount": 200, "currency": "USD" }, "source": "PROMO_CODE", "application_scope": "PRE_TAX" } ], "promo_codes": [ { "code": "SAVE2", "status": "ACTIVE", "discount_preview": { "estimated_discount": { "amount": 200, "currency": "USD" }, "description": "$2 off your order" }, "applied_at": "2026-02-01T15:00:00Z" } ], "member_pricing_applied": false, "fees": [], "subtotal": { "amount": 1000, "currency": "USD" }, "total_tax": { "amount": 80, "currency": "USD" }, "total_discount": { "amount": 200, "currency": "USD" }, "total_fees": { "amount": 0, "currency": "USD" }, "taxable_amount": { "amount": 800, "currency": "USD" }, "total": { "amount": 880, "currency": "USD" }, "calculated_at": "2026-02-01T15:00:05Z" } ``` Key fields to verify: - `subtotal`: 1000 (the item price before cart-level discounts) - `total_discount`: 200 (the $2 promo code discount) - `taxable_amount`: 800 (subtotal minus pre-tax discounts: 1000 - 200) - `total_tax`: 80 (10% of taxable_amount) - `total`: 880 (subtotal + total_tax - total_discount: 1000 + 80 - 200) > **All discount sources currently use `PRE_TAX` scope.** This is the recommended default for loyalty programs in convenience stores, where reducing the taxable amount provides a clear, tangible benefit to the customer. ## Discount Timing & Staleness Discounts may change between `/calculate` and `/checkout`. Several things can cause the calculated total to become stale: - A promo code may expire (time-limited promotions) - An automatic discount may no longer apply (e.g., happy hour ended) - Item prices may change (menu update) - Items may become unavailable (out of stock) - Fees may change (delivery fee recalculation) ### Optimistic Concurrency with expected_total Checkout uses `expected_total` to detect staleness. If the total has changed since your last calculation, checkout returns `409 Conflict` with `change_reasons` explaining what changed. **409 Conflict response example:** ```json { "error": { "code": "CONFLICT_ERROR", "message": "Price has changed since your last calculation.", "detail": "The expected total of 880 does not match the current total of 1080.", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "field": "expected_total", "change_reasons": ["PROMO_EXPIRED"] } } ``` The `change_reasons` array tells you what changed. Possible values: | Reason | What Changed | | --- | --- | | `PROMO_EXPIRED` | A promo code expired or was invalidated | | `DISCOUNT_CHANGED` | An automatic discount changed or was removed | | `ITEM_PRICE_CHANGED` | One or more item prices were updated | | `ITEM_UNAVAILABLE` | One or more items are no longer available | | `FEE_CHANGED` | A fee amount changed (delivery, service, etc.) | ### Recovery Flow When you receive a 409, re-calculate prices, show the updated total to the customer, and retry checkout with the new total. ```mermaid sequenceDiagram participant Client participant API as Tote API Client->>API: POST /carts/{id}/checkout
{ "expected_total": 880 } API-->>Client: 409 Conflict
{ change_reasons: ["PROMO_EXPIRED"] } Note over Client: Show user: "A promo has expired.
Your total has changed." Client->>API: POST /carts/{id}/calculate API-->>Client: PriceCalculation
{ total: 1080 } Note over Client: Show updated price: "$10.80"
Previously: "$8.80" Client->>API: POST /carts/{id}/checkout
{ "expected_total": 1080 } API-->>Client: 201 Created
Order (PENDING) ``` ### When to Re-Calculate Follow these guidelines to minimize staleness: - **Always re-calculate** when showing the checkout summary screen - **Re-calculate after** any cart modification (add/remove items, apply/remove promo code, change `customer_id`) - **Do not cache** `PriceCalculation` results for extended periods -- prices are computed at request time and may change > **Tip:** The `calculated_at` timestamp on the `PriceCalculation` response tells you when the prices were last computed. If the timestamp is more than a few minutes old, re-calculate before proceeding to checkout. ## What's Next - Use the [Integration Checklist](/online-ordering/guides/11-integration-checklist) to verify your promo code handling in the sandbox, including applying, removing, and testing rejection scenarios. - Review the [C-Store Features guide](/online-ordering/guides/10-cstore-features) for `non_discountable` items and tender restrictions that interact with discount calculations. - See the [Error Code Reference](/online-ordering/reference/error-codes) for all v2 error codes, including promo code validation errors, customer ID validation errors, and fee-related error scenarios.