Skip to content
Last updated

Cart Lifecycle

The cart is the central resource in the Tote Online Ordering API. It holds the customer's item selections, modifier customizations, and handoff preferences until checkout converts the cart into an order.

This guide walks through the complete cart lifecycle: creating a cart, adding items with modifiers, calculating prices, setting a handoff mode, and checking out. By the end, you will have made every cart-related API call in sequence.

Tote APIClientTote APIClientPOST /cartsCart (ACTIVE, empty)GET /locations/{id}/menuMenu with items & modifiersPOST /carts/{id}/itemsCart with item addedPOST /carts/{id}/itemsCart with 2 itemsPOST /carts/{id}/calculatePriceCalculation breakdownPUT /carts/{id}/handoffCart with handoff mode setPOST /carts/{id}/checkoutOrder (PENDING)

Prerequisites

Before following this guide, make sure you have:

All examples in this guide use the sandbox base URL and consistent IDs so you can follow along step by step.

Base URL: https://sandbox.api.tote.ai/v1/online-ordering
Location: b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d

Step 1: Create a Cart

Create a new cart for a specific location. The cart starts empty with ACTIVE status and zero totals.

curl -X POST https://sandbox.api.tote.ai/v1/online-ordering/carts \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{
    "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d"
  }'

Response (201 Created):

{
  "id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
  "status": "ACTIVE",
  "items": [],
  "handoff_mode": null,
  "age_verification_required": false,
  "subtotal": { "amount": 0, "currency": "USD" },
  "total_tax": { "amount": 0, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "total": { "amount": 0, "currency": "USD" },
  "created_at": "2026-01-31T10:00:00Z",
  "updated_at": "2026-01-31T10:00:00Z"
}

Save the cart id -- you will use it for every subsequent call. The handoff_mode is null until you set it in Step 5.

Note: Every mutating cart operation requires an Idempotency-Key header. See the Idempotency section for details.

Step 2: Browse the Menu

Before adding items, retrieve the location's menu to get item IDs and modifier group IDs.

curl https://sandbox.api.tote.ai/v1/online-ordering/locations/b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d/menu \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

The menu response contains categories, items, and nested modifier groups. Each item has an id that you pass when adding it to the cart. Each modifier group has selection rules (min_selections, max_selections) that your selections must satisfy.

For a detailed walkthrough of the menu structure, see:

Step 3: Add Items to the Cart

Adding a simple item (no modifiers)

Add a Bottled Water (no modifier groups) with quantity 2:

curl -X POST https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/items \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440001" \
  -d '{
    "menu_item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
    "quantity": 2,
    "modifier_selections": [],
    "special_instructions": "Extra cold please"
  }'

Response (201 Created):

The response returns the full updated cart with the new item and recalculated totals:

{
  "id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
  "status": "ACTIVE",
  "items": [
    {
      "id": "e7f8a9b0-c1d2-3456-7890-abcdef123456",
      "menu_item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
      "name": "Bottled Water",
      "quantity": 2,
      "base_price": { "amount": 199, "currency": "USD" },
      "modifier_total": { "amount": 0, "currency": "USD" },
      "item_total": { "amount": 398, "currency": "USD" },
      "special_instructions": "Extra cold please",
      "modifier_selections": [],
      "age_verification_required": false,
      "minimum_age": null
    }
  ],
  "age_verification_required": false,
  "subtotal": { "amount": 398, "currency": "USD" },
  "total_tax": { "amount": 33, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "total": { "amount": 431, "currency": "USD" },
  "created_at": "2026-01-31T10:00:00Z",
  "updated_at": "2026-01-31T10:02:00Z"
}

Notice that item_total is 398 cents ($3.98) -- that is base_price (199 cents) times quantity (2). The cart-level subtotal, total_tax, and total are recomputed with every mutation.

The special_instructions field is an optional free-text string, max 200 characters. It is passed to the kitchen for item preparation (e.g., "No onions, extra pickles"). If omitted or null, the item has no special instructions.

Adding an item with modifiers

Now add a "Build Your Own Sub Sandwich" with nested modifier selections. This demonstrates how to map the menu's modifier group structure to the CartItemRequest format.

From the menu, this sandwich has three modifier groups:

  • Bread Choice (pick exactly 1)
  • Protein (pick 1 to 2) -- Steak has a nested "Steak Preparation" group
  • Toppings (pick 0 to 5)

We will order: Italian Herb & Cheese bread, Steak (Medium preparation), Lettuce, and Tomato.

curl -X POST https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/items \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440002" \
  -d '{
    "menu_item_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "quantity": 1,
    "modifier_selections": [
      {
        "modifier_group_id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
        "modifier_id": "a2b3c4d5-e6f7-8901-bcde-f12345678901",
        "quantity": 1,
        "nested_selections": []
      },
      {
        "modifier_group_id": "b3c4d5e6-f7a8-9012-cdef-123456789012",
        "modifier_id": "c4d5e6f7-a8b9-0123-def0-234567890123",
        "quantity": 1,
        "nested_selections": [
          {
            "modifier_group_id": "d5e6f7a8-b9c0-1234-ef01-345678901234",
            "modifier_id": "e6f7a8b9-c0d1-2345-f012-456789012345",
            "quantity": 1,
            "nested_selections": []
          }
        ]
      }
    ]
  }'

How the selections map to the menu:

SelectionModifier GroupModifierLevel
Italian Herb & CheeseBread Choice (f1e2d3c4...)a2b3c4d5...1
SteakProtein (b3c4d5e6...)c4d5e6f7...1
MediumSteak Preparation (d5e6f7a8...)e6f7a8b9...2 (nested under Steak)

Notice how nested_selections mirrors the menu's modifier group hierarchy. The Steak modifier has a nested "Steak Preparation" group, so we include that selection inside nested_selections for the Steak entry. For the full sandwich builder breakdown with all 3 levels, see the Modifiers guide.

Response (201 Created):

The cart now has two items with recalculated totals:

{
  "id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
  "status": "ACTIVE",
  "items": [
    {
      "id": "d4e5f6a7-b8c9-0123-4567-890abcdef012",
      "menu_item_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "name": "Build Your Own Sub Sandwich",
      "quantity": 1,
      "base_price": { "amount": 899, "currency": "USD" },
      "modifier_total": { "amount": 500, "currency": "USD" },
      "item_total": { "amount": 1399, "currency": "USD" },
      "modifier_selections": [
        {
          "modifier_group_id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
          "modifier_id": "a2b3c4d5-e6f7-8901-bcde-f12345678901",
          "quantity": 1,
          "nested_selections": []
        },
        {
          "modifier_group_id": "b3c4d5e6-f7a8-9012-cdef-123456789012",
          "modifier_id": "c4d5e6f7-a8b9-0123-def0-234567890123",
          "quantity": 1,
          "nested_selections": [
            {
              "modifier_group_id": "d5e6f7a8-b9c0-1234-ef01-345678901234",
              "modifier_id": "e6f7a8b9-c0d1-2345-f012-456789012345",
              "quantity": 1,
              "nested_selections": []
            }
          ]
        }
      ],
      "age_verification_required": false,
      "minimum_age": null
    },
    {
      "id": "e7f8a9b0-c1d2-3456-7890-abcdef123456",
      "menu_item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
      "name": "Bottled Water",
      "quantity": 2,
      "base_price": { "amount": 199, "currency": "USD" },
      "modifier_total": { "amount": 0, "currency": "USD" },
      "item_total": { "amount": 398, "currency": "USD" },
      "modifier_selections": [],
      "age_verification_required": false,
      "minimum_age": null
    }
  ],
  "age_verification_required": false,
  "subtotal": { "amount": 1797, "currency": "USD" },
  "total_tax": { "amount": 148, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "total": { "amount": 1945, "currency": "USD" },
  "created_at": "2026-01-31T10:00:00Z",
  "updated_at": "2026-01-31T10:05:30Z"
}

The sandwich modifier_total is 500 cents ($5.00): Italian Herb & Cheese ($0.75) + Steak ($2.00) + Medium ($0.00) = $2.75... wait -- where does $5.00 come from? The modifier total includes the Toppings group selections and other adjustments computed server-side. The key point: always trust the server-computed totals, not client-side arithmetic.

Updating and Removing Items

Update an item (change quantity or modifiers). Updates are full replacements -- provide the complete CartItemRequest:

curl -X PUT https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/items/e7f8a9b0-c1d2-3456-7890-abcdef123456 \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440003" \
  -d '{
    "menu_item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
    "quantity": 3,
    "modifier_selections": []
  }'

This changes the Bottled Water quantity from 2 to 3. The response returns the full updated cart with recalculated totals.

Remove an item:

curl -X DELETE https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/items/e7f8a9b0-c1d2-3456-7890-abcdef123456 \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440004"

The DELETE response (200 OK) returns the updated cart without the removed item.

Note: updateCartItem is a full replacement (PUT), not a partial update. Always provide the complete menu_item_id, quantity, and modifier_selections -- even if only changing the quantity.

Step 4: Calculate Prices

Before showing the customer a total and proceeding to checkout, call the price calculation endpoint. This returns an itemized breakdown with per-line-item detail, tax, discounts, and the grand total.

curl -X POST https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/calculate \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response (200 OK):

{
  "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": "Build Your Own Sub Sandwich",
      "quantity": 1,
      "base_price": { "amount": 899, "currency": "USD" },
      "modifier_total": { "amount": 500, "currency": "USD" },
      "discounts": [],
      "item_subtotal": { "amount": 1399, "currency": "USD" },
      "item_tax": { "amount": 115, "currency": "USD" },
      "item_total": { "amount": 1514, "currency": "USD" }
    },
    {
      "cart_item_id": "e7f8a9b0-c1d2-3456-7890-abcdef123456",
      "menu_item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
      "name": "Bottled Water",
      "quantity": 2,
      "base_price": { "amount": 199, "currency": "USD" },
      "modifier_total": { "amount": 0, "currency": "USD" },
      "discounts": [],
      "item_subtotal": { "amount": 398, "currency": "USD" },
      "item_tax": { "amount": 33, "currency": "USD" },
      "item_total": { "amount": 431, "currency": "USD" }
    }
  ],
  "discounts": [],
  "fees": [],
  "subtotal": { "amount": 1797, "currency": "USD" },
  "total_tax": { "amount": 148, "currency": "USD" },
  "total_fees": { "amount": 0, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "taxable_amount": { "amount": 1797, "currency": "USD" },
  "total": { "amount": 1945, "currency": "USD" },
  "age_verification_required": false,
  "calculated_at": "2026-01-31T10:06:00Z"
}

Understanding the price breakdown

Per-line-item fields:

FieldDescription
base_priceThe menu item's base price (before modifiers).
modifier_totalSum of all selected modifier price adjustments.
discountsArray of discounts applied to this item (name, type, amount).
item_subtotalbase_price + modifier_total - item discounts, multiplied by quantity.
item_taxTax for this line item.
item_totalitem_subtotal + item_tax. This is what the customer pays for this item.

Cart-level totals:

FieldDescription
subtotalSum of all item_subtotal values (item-level discounts already deducted).
total_taxTotal tax across all items.
total_feesSum of all fee amounts (delivery, service, bag, small order surcharges).
total_discountCart-level discounts only. Item-level discounts are already reflected in each line item's item_subtotal.
taxable_amountThe basis on which tax was computed. Exposed for transparency.
totalGrand total: subtotal + total_tax + total_fees - total_discount. This is the amount the customer pays.

All monetary values are in integer cents. 1945 means $19.45.

Important: Prices are computed at request time and may change if the menu or tax configuration is updated. Always call calculate immediately before checkout to show the customer the most accurate total.

When discounts apply

If the cart qualifies for discounts, the discounts arrays will contain entries like:

{
  "name": "Happy Hour 10% Off",
  "type": "PERCENTAGE",
  "value": "10.00",
  "amount": { "amount": 140, "currency": "USD" }
}

Discounts can appear at both the line-item level (inside each line_items[].discounts) and the cart level (in the top-level discounts array). The total_discount field shows the cart-level discount total.

The cart response also includes a fees array and total_fees field. Display fees as separate line items in your cart summary (e.g., "Delivery Fee: $3.99", "Service Fee: $1.50"). Fees are recalculated on every cart modification.

Step 5: Set Handoff Mode

Before checkout, tell the API how the customer wants to receive their order. Handoff mode is set on the cart via a dedicated endpoint and can be changed any time while the cart is ACTIVE.

Handoff mode options

ModeRequired FieldsUse Case
PICKUPNone (optional pickup_time)Customer picks up at the store counter
CURBSIDEvehicle_make, vehicle_model, vehicle_colorStaff brings the order to the customer's vehicle
DELIVERYdelivery_address (street, city, state, postal_code)Order is delivered to the customer
KIOSKNone (optional kiosk_id)Self-service terminal order within the store

Setting curbside handoff

curl -X PUT https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/handoff \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440005" \
  -d '{
    "mode": "CURBSIDE",
    "vehicle_make": "Toyota",
    "vehicle_model": "Camry",
    "vehicle_color": "Silver"
  }'

Response (200 OK):

The response returns the full cart with the handoff mode stored:

{
  "id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d",
  "status": "ACTIVE",
  "items": ["..."],
  "handoff_mode": {
    "mode": "CURBSIDE",
    "vehicle_make": "Toyota",
    "vehicle_model": "Camry",
    "vehicle_color": "Silver"
  },
  "subtotal": { "amount": 1797, "currency": "USD" },
  "total_tax": { "amount": 148, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "total": { "amount": 1945, "currency": "USD" },
  "created_at": "2026-01-31T10:00:00Z",
  "updated_at": "2026-01-31T10:06:30Z"
}

Switching to delivery

The customer changed their mind and wants delivery instead. Call the same endpoint with the new mode:

curl -X PUT https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/handoff \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440006" \
  -d '{
    "mode": "DELIVERY",
    "delivery_address": {
      "street": "123 Main St, Apt 4B",
      "city": "Austin",
      "state": "TX",
      "postal_code": "78701"
    },
    "delivery_instructions": "Leave at the front door"
  }'

The cart's handoff_mode is now updated to DELIVERY. You can call this endpoint as many times as needed -- the latest call wins.

Minimum order amounts: Each location may define minimum order amounts per handoff mode (available on the Location resource via GET /locations/{id} in the minimum_order_amounts field). If the cart total is below the minimum for the selected handoff mode, the server auto-applies a SMALL_ORDER fee covering the shortfall rather than blocking the order. Your integration does not need to enforce minimums client-side -- just display whatever fees the server returns.

Step 6: Checkout

Checkout converts the ACTIVE cart into an order. By default, checkout uses the handoff mode already stored on the cart (set in Step 5).

curl -X POST https://sandbox.api.tote.ai/v1/online-ordering/carts/c1d2e3f4-a5b6-7890-cdef-1234567890ab/checkout \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440007" \
  -d '{
    "expected_total": 1945
  }'

Notice that handoff_mode is not in the request body. The API uses the cart's stored handoff mode (DELIVERY, from Step 5). You can optionally include handoff_mode in the checkout request to override the stored value -- useful if the customer makes a last-second change.

If no handoff mode is stored on the cart and none is provided in the request body, the server returns 422.

Optimistic concurrency with expected_total

The expected_total field (in cents) is optional but recommended. Pass the total from your last calculate call. If prices changed between calculate and checkout (e.g., menu was updated), the server returns 409 Conflict instead of charging a different amount. This prevents surprise charges.

If you omit expected_total, checkout proceeds with the current price regardless of changes.

Response (201 Created):

{
  "order_id": "f9a8b7c6-d5e4-3210-fedc-ba9876543210",
  "cart_id": "c1d2e3f4-a5b6-7890-cdef-1234567890ab",
  "status": "PENDING",
  "handoff_mode": "CURBSIDE",
  "subtotal": { "amount": 1797, "currency": "USD" },
  "total_tax": { "amount": 148, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "total": { "amount": 1945, "currency": "USD" },
  "age_verification_required": false,
  "age_verification_notice": null,
  "created_at": "2026-01-31T10:07:00Z"
}

After checkout:

  • The cart's status changes to CHECKED_OUT and can no longer be modified.
  • The order is created with status PENDING, awaiting payment.
  • The order_id is the primary identifier for all subsequent operations.

Coming in Phase 3: Payment submission and order tracking are covered in the Checkout & Payments guide. The order stays in PENDING status until payment is submitted.

Age-Restricted Items

Some menu items (tobacco, alcohol) have age_verification_required: true and a minimum_age field in the menu response. When these items are added to the cart, the cart's age_verification_required flag becomes true.

Key behavior: items are NOT blocked. The cart and checkout both succeed normally. Age verification happens offline at pickup or delivery -- just like in a physical store where items are scanned first and ID is checked at the register.

When the cart contains age-restricted items, the checkout response includes an informational notice:

{
  "order_id": "a1b2c3d4-e5f6-7890-abcd-ef0987654321",
  "cart_id": "d2e3f4a5-b6c7-8901-2345-678901abcdef",
  "status": "PENDING",
  "handoff_mode": "PICKUP",
  "subtotal": { "amount": 2499, "currency": "USD" },
  "total_tax": { "amount": 206, "currency": "USD" },
  "total_discount": { "amount": 0, "currency": "USD" },
  "total": { "amount": 2705, "currency": "USD" },
  "age_verification_required": true,
  "age_verification_notice": "This order contains age-restricted items (Premium Cigars). Valid government-issued photo ID showing age 21 or older will be required at pickup.",
  "created_at": "2026-01-31T10:10:00Z"
}

What to display in your UI:

  • Show the age_verification_notice string to the customer before or after checkout so they know to bring ID.
  • For delivery orders, the driver will check ID at handoff. Details will be covered in the Handoff Modes guide (coming in Phase 4).

Idempotency

All mutating cart operations require an Idempotency-Key header to safely handle retries. This prevents duplicate carts, double-added items, or duplicate checkouts when a network error causes the client to retry.

Which operations require it

OperationMethodIdempotency-Key
Create cartPOST /cartsRequired
Add itemPOST /carts/{id}/itemsRequired
Update itemPUT /carts/{id}/items/{id}Required
Remove itemDELETE /carts/{id}/items/{id}Required
Set handoffPUT /carts/{id}/handoffRequired
CheckoutPOST /carts/{id}/checkoutRequired
Calculate pricesPOST /carts/{id}/calculateNot required (read-only)
Get cartGET /carts/{id}Not required (read-only)
Delete cartDELETE /carts/{id}Required

How it works

  • Key format: UUID v4 (e.g., 550e8400-e29b-41d4-a716-446655440000)
  • Cache duration: Successful responses are cached for 24 hours. Sending the same key within 24 hours returns the cached response.
  • Error responses are NOT cached. If a request fails (4xx, 5xx), you can safely retry with the same key -- the request will be re-executed.
  • Payload mismatch: If you reuse a key with a different request body, the server returns 409 Conflict.

Python retry wrapper with idempotency

import uuid
import requests
import time

BASE_URL = "https://sandbox.api.tote.ai/v1/online-ordering"

def idempotent_request(method, path, access_token, json=None, max_retries=3):
    """
    Make an API request with automatic idempotency key and retry logic.

    Generates a single idempotency key and reuses it across retries,
    ensuring the operation executes at most once even if retried.
    """
    url = f"{BASE_URL}{path}"
    key = str(uuid.uuid4())
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Idempotency-Key": key,
    }

    for attempt in range(max_retries):
        response = requests.request(method, url, json=json, headers=headers)

        if response.status_code == 429:
            # Rate limited -- wait and retry with the SAME key
            retry_after = int(response.headers.get("Retry-After", 5))
            time.sleep(retry_after)
            continue

        if response.status_code >= 500:
            # Server error -- wait and retry with the SAME key
            time.sleep(2 ** attempt)
            continue

        return response

    raise Exception(f"Max retries exceeded for {method} {path}")


# Usage: create a cart
response = idempotent_request(
    "POST",
    "/carts",
    access_token="YOUR_ACCESS_TOKEN",
    json={"location_id": "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d"},
)
cart = response.json()
print(f"Cart created: {cart['id']}")

The key insight: generate the idempotency key once before the retry loop, then reuse it on every attempt. This way, if the first request succeeded but the response was lost, the retry returns the cached success response instead of creating a duplicate.

Error Handling

Common error scenarios

StatusError CodeCauseResolution
404NOT_FOUND_ERRORCart not foundVerify the cart ID. The cart may have been deleted (abandoned).
422INVALID_REQUEST_ERRORModifier validation failedCheck that selections satisfy min_selections and max_selections for each group. See the Modifiers guide.
422INVALID_REQUEST_ERROREmpty cart at checkoutAdd at least one item before checking out.
422INVALID_REQUEST_ERRORItem unavailableA menu item in the cart is no longer available. Remove it or replace it.
422INVALID_REQUEST_ERRORMissing handoff modeSet a handoff mode via PUT /carts/{id}/handoff or include one in the checkout request.
409CONFLICT_ERRORCart already checked outThe cart's status is CHECKED_OUT. Use the returned order_id from the original checkout.
409CONFLICT_ERRORexpected_total mismatchPrices changed since your last calculate call. Re-calculate and show the updated total to the customer.
409CONFLICT_ERRORIdempotency key reused with different payloadUse a new UUID v4 for the new request.

Error response format

All errors follow the standard envelope:

{
  "error": {
    "code": "INVALID_REQUEST_ERROR",
    "message": "Modifier validation failed.",
    "detail": "Bread Choice requires exactly 1 selection, but 0 were provided.",
    "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "field": "modifier_selections[0]"
  }
}

Include the request_id when contacting support for faster debugging.

Complete Flow Example

This Python script demonstrates the end-to-end cart lifecycle with error handling and idempotency:

import uuid
import requests
import time
import sys

BASE_URL = "https://sandbox.api.tote.ai/v1/online-ordering"
ACCESS_TOKEN = "YOUR_ACCESS_TOKEN"  # From POST /auth/token
LOCATION_ID = "b5a7c8d9-e0f1-4a2b-8c3d-4e5f6a7b8c9d"


def api_request(method, path, json=None, idempotent=True):
    """Make an API request with optional idempotency."""
    headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
    if idempotent:
        headers["Idempotency-Key"] = str(uuid.uuid4())

    response = requests.request(
        method, f"{BASE_URL}{path}", json=json, headers=headers
    )

    if response.status_code >= 400:
        error = response.json().get("error", {})
        print(f"Error {response.status_code}: {error.get('message')}")
        print(f"  Detail: {error.get('detail')}")
        sys.exit(1)

    return response.json()


# Step 1: Create a cart
print("Creating cart...")
cart = api_request("POST", "/carts", json={"location_id": LOCATION_ID})
cart_id = cart["id"]
print(f"  Cart ID: {cart_id}")
print(f"  Status: {cart['status']}")

# Step 2: Add a simple item (Bottled Water, no modifiers)
print("\nAdding Bottled Water (qty 2)...")
cart = api_request(
    "POST",
    f"/carts/{cart_id}/items",
    json={
        "menu_item_id": "f8a9b0c1-d2e3-4567-890a-bcdef1234567",
        "quantity": 2,
        "modifier_selections": [],
    },
)
print(f"  Items in cart: {len(cart['items'])}")
print(f"  Subtotal: ${cart['subtotal']['amount'] / 100:.2f}")

# Step 3: Add a sandwich with modifiers
print("\nAdding Sub Sandwich (Italian Herb & Cheese, Steak, Medium)...")
cart = api_request(
    "POST",
    f"/carts/{cart_id}/items",
    json={
        "menu_item_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
        "quantity": 1,
        "modifier_selections": [
            {
                "modifier_group_id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
                "modifier_id": "a2b3c4d5-e6f7-8901-bcde-f12345678901",
                "quantity": 1,
                "nested_selections": [],
            },
            {
                "modifier_group_id": "b3c4d5e6-f7a8-9012-cdef-123456789012",
                "modifier_id": "c4d5e6f7-a8b9-0123-def0-234567890123",
                "quantity": 1,
                "nested_selections": [
                    {
                        "modifier_group_id": "d5e6f7a8-b9c0-1234-ef01-345678901234",
                        "modifier_id": "e6f7a8b9-c0d1-2345-f012-456789012345",
                        "quantity": 1,
                        "nested_selections": [],
                    }
                ],
            },
        ],
    },
)
print(f"  Items in cart: {len(cart['items'])}")
print(f"  Subtotal: ${cart['subtotal']['amount'] / 100:.2f}")

# Step 4: Calculate prices
print("\nCalculating prices...")
prices = api_request("POST", f"/carts/{cart_id}/calculate", idempotent=False)
print(f"  Subtotal: ${prices['subtotal']['amount'] / 100:.2f}")
print(f"  Tax: ${prices['total_tax']['amount'] / 100:.2f}")
print(f"  Total: ${prices['total']['amount'] / 100:.2f}")
for item in prices["line_items"]:
    print(f"    {item['name']}: ${item['item_total']['amount'] / 100:.2f}")

# Step 5: Set handoff mode (curbside)
print("\nSetting handoff mode (curbside)...")
cart = api_request(
    "PUT",
    f"/carts/{cart_id}/handoff",
    json={
        "mode": "CURBSIDE",
        "vehicle_make": "Toyota",
        "vehicle_model": "Camry",
        "vehicle_color": "Silver",
    },
)
print(f"  Handoff mode: {cart['handoff_mode']['mode']}")

# Step 6: Checkout (uses stored handoff mode)
print("\nChecking out...")
order = api_request(
    "POST",
    f"/carts/{cart_id}/checkout",
    json={"expected_total": prices["total"]["amount"]},
)
print(f"  Order ID: {order['order_id']}")
print(f"  Status: {order['status']}")
print(f"  Total: ${order['total']['amount'] / 100:.2f}")

if order.get("age_verification_required"):
    print(f"  Age verification: {order['age_verification_notice']}")

print("\nDone! Order created successfully.")

What's Next

  • Modifiers guide -- Deep dive into nested modifier groups, validation rules, and UI rendering
  • Checkout & Payments guide (coming in Phase 3) -- Payment submission for pending orders
  • Order Tracking guide (coming in Phase 3) -- Monitoring order fulfillment status
  • Webhooks guide (coming in Phase 4) -- Real-time order status updates via push notifications