Skip to content
Last updated

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 SourceApplication ScopeWhen AppliedExample
AUTOMATICPRE_TAXDuring /calculateHappy Hour 10% Off
PROMO_CODEPRE_TAXAfter code appliedSUMMER25 -- $2 off subs
LOYALTY_REWARDPRE_TAXDuring /calculateMember 5% discount
MANUALPRE_TAXPOS staff overrideManager comp

Note: All current discount sources use PRE_TAX scope, reducing the taxable amount before tax calculation. See the Loyalty-as-Discount 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):

{
  "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
{
  "code": "summer25"
}

Response (200 OK): The updated Cart with the promo code in the promo_codes array.

{
  "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:

{
  "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 ReasonDescriptionSuggested UX
INVALID_CODECode not recognized"This code is not valid"
EXPIREDPast expiration date"This code has expired"
ALREADY_USEDSingle-use, already redeemed"This code has already been used"
MINIMUM_NOT_METCart below minimum amount"Add $X more to use this code"
NOT_APPLICABLENo eligible items in cart"This code doesn't apply to items in your cart"
ALREADY_APPLIEDAnother 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

CategoryExampleHow It Works
Time-basedHappy hour pricing, weekend specialsServer 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-specificStore grand opening discountsDiscount tied to a specific location ID

Automatic discounts appear in the PriceCalculation.discounts array with source: AUTOMATIC:

{
  "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 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 TypeDescriptionExample
DELIVERYFee for delivering the order to the customer"Delivery Fee: $3.99"
SERVICEPlatform or convenience service fee"Service Fee: $1.50"
BAGPer-bag charge required by local regulations"Bag Fee: $0.10"
SMALL_ORDERSurcharge when cart subtotal is below the location's minimum"Small Order Fee: $2.00"
OTHERFees 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 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:

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}
{
  "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.

{
  "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:

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:

{
  "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:

{
  "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:

ReasonWhat Changed
PROMO_EXPIREDA promo code expired or was invalidated
DISCOUNT_CHANGEDAn automatic discount changed or was removed
ITEM_PRICE_CHANGEDOne or more item prices were updated
ITEM_UNAVAILABLEOne or more items are no longer available
FEE_CHANGEDA 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.

Tote APIClientTote APIClientShow user: "A promo has expired.Your total has changed."Show updated price: "$10.80"Previously: "$8.80"POST /carts/{id}/checkout{ "expected_total": 880 }409 Conflict{ change_reasons: ["PROMO_EXPIRED"] }POST /carts/{id}/calculatePriceCalculation{ total: 1080 }POST /carts/{id}/checkout{ "expected_total": 1080 }201 CreatedOrder (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 to verify your promo code handling in the sandbox, including applying, removing, and testing rejection scenarios.
  • Review the C-Store Features guide for non_discountable items and tender restrictions that interact with discount calculations.
  • See the Error Code Reference for all v2 error codes, including promo code validation errors, customer ID validation errors, and fee-related error scenarios.