Skip to content
Last updated

Understanding Modifiers

Modifiers let customers customize menu items -- choosing bread type, adding toppings, selecting a preparation style. In the Tote API, modifiers are organized into modifier groups that can nest up to 3 levels deep, enabling complex customization trees like "Sandwich > Steak > Medium > Chimichurri Sauce."

This guide explains the modifier data model, walks through a fully annotated 3-level worked example, and covers pricing, validation, default selections, and UI rendering tips.

What Are Modifiers?

A modifier is a customization option for a menu item (e.g., "Cheddar Cheese", "Medium", "A1 Steak Sauce").

A modifier group is a named collection of modifiers with selection rules (e.g., "Bread Choice: pick exactly 1", "Toppings: pick up to 5").

Menu items contain modifier groups. Each modifier within a group can itself contain nested modifier groups, creating a tree structure. The API supports up to 3 levels of nesting.

Menu Item
  |-- Modifier Group (Level 1)
  |     |-- Modifier
  |     |     |-- Modifier Group (Level 2)
  |     |           |-- Modifier
  |     |           |     |-- Modifier Group (Level 3)
  |     |           |           |-- Modifier
  |     |           |           |-- Modifier
  |     |           |-- Modifier
  |     |-- Modifier
  |-- Modifier Group (Level 1)
        |-- Modifier
        |-- Modifier

Modifier Group Rules

Every modifier group defines selection constraints using three fields:

FieldTypeDescription
min_selectionsintegerMinimum modifiers the customer must select. 0 = optional.
max_selectionsintegerMaximum modifiers the customer may select.
allows_duplicatesbooleanWhether the same modifier can be selected more than once.

Common Patterns

PatternminmaxduplicatesExample
Required single-select11falseBread Choice (pick exactly 1)
Required multi-select13falseProtein (pick 1 to 2)
Optional multi-select05falseToppings (pick up to 5)
Optional with duplicates03trueExtra Cheese (add up to 3x)
Required with duplicates110trueScoops (pick 1 to 10, same flavor OK)

Worked Example: Build Your Own Sub Sandwich

This is the centerpiece of the modifier system. We will walk through a real menu item from the API with all 3 levels of nesting, showing the JSON at each level.

The Menu Item

The "Build Your Own Sub Sandwich" has three modifier groups at Level 1:

{
  "id": "d0000001-0000-0000-0000-000000000001",
  "name": "Build Your Own Sub Sandwich",
  "description": "Choose your bread, protein, and toppings.",
  "base_price": { "amount": 899, "currency": "USD" },
  "available": true,
  "modifier_groups": [
    { "name": "Bread Choice", "..." : "Level 1 group" },
    { "name": "Protein", "..." : "Level 1 group" },
    { "name": "Toppings", "..." : "Level 1 group" }
  ]
}

Base price: $8.99 (899 cents).

Level 1: Bread Choice (Single-Select, Required)

The customer must pick exactly one bread.

{
  "id": "mg000001-0000-0000-0000-000000000001",
  "name": "Bread Choice",
  "min_selections": 1,
  "max_selections": 1,
  "allows_duplicates": false,
  "modifiers": [
    {
      "id": "m0000001-0000-0000-0000-000000000001",
      "name": "White",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": true,
      "modifier_groups": []
    },
    {
      "id": "m0000001-0000-0000-0000-000000000002",
      "name": "Wheat",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000001-0000-0000-0000-000000000003",
      "name": "Italian Herb & Cheese",
      "price_adjustment": { "amount": 75, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    }
  ]
}

Key observations:

  • min_selections: 1 and max_selections: 1 -- the customer must pick exactly one bread.
  • White is is_default: true -- pre-selected in the UI.
  • White and Wheat cost $0 extra. Italian Herb & Cheese adds $0.75 (75 cents).
  • modifier_groups: [] -- no further nesting. This is a leaf-level group.

Level 1: Protein (Multi-Select, Required)

The customer must pick 1 to 2 proteins.

{
  "id": "mg000001-0000-0000-0000-000000000002",
  "name": "Protein",
  "min_selections": 1,
  "max_selections": 2,
  "allows_duplicates": false,
  "modifiers": [
    {
      "id": "m0000002-0000-0000-0000-000000000001",
      "name": "Turkey",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000002-0000-0000-0000-000000000002",
      "name": "Ham",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000002-0000-0000-0000-000000000003",
      "name": "Steak",
      "price_adjustment": { "amount": 200, "currency": "USD" },
      "is_default": false,
      "modifier_groups": [
        { "name": "Steak Preparation", "..." : "Level 2 group (see below)" }
      ]
    }
  ]
}

Key observations:

  • max_selections: 2 -- the customer can pick up to two proteins (e.g., Turkey + Ham).
  • Turkey and Ham cost $0 extra. Steak adds $2.00 (200 cents).
  • Steak has a nested modifier group -- selecting Steak unlocks "Steak Preparation" at Level 2.
  • Turkey and Ham have modifier_groups: [] -- they are leaf-level modifiers.

Level 2: Steak Preparation (Single-Select, Required)

When the customer selects Steak, they must choose a preparation level.

{
  "id": "mg000002-0000-0000-0000-000000000001",
  "name": "Steak Preparation",
  "min_selections": 1,
  "max_selections": 1,
  "allows_duplicates": false,
  "modifiers": [
    {
      "id": "m0000003-0000-0000-0000-000000000001",
      "name": "Rare",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000003-0000-0000-0000-000000000002",
      "name": "Medium",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": true,
      "modifier_groups": [
        { "name": "Sauce", "..." : "Level 3 group (see below)" }
      ]
    },
    {
      "id": "m0000003-0000-0000-0000-000000000003",
      "name": "Well Done",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    }
  ]
}

Key observations:

  • This is a Level 2 group -- nested inside the Steak modifier.
  • Medium is is_default: true -- pre-selected when the customer picks Steak.
  • All preparations cost $0 extra (the $2.00 upcharge is on the Steak modifier itself).
  • Medium has a nested modifier group -- selecting Medium unlocks "Sauce" at Level 3.
  • Rare and Well Done have modifier_groups: [] -- they are leaf-level.

Level 3: Sauce (Multi-Select, Optional)

When the customer selects Medium steak, they can optionally choose up to 2 sauces.

{
  "id": "mg000003-0000-0000-0000-000000000001",
  "name": "Sauce",
  "min_selections": 0,
  "max_selections": 2,
  "allows_duplicates": false,
  "modifiers": [
    {
      "id": "m0000004-0000-0000-0000-000000000001",
      "name": "A1 Steak Sauce",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000004-0000-0000-0000-000000000002",
      "name": "Peppercorn Ranch",
      "price_adjustment": { "amount": 50, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000004-0000-0000-0000-000000000003",
      "name": "Chimichurri",
      "price_adjustment": { "amount": 75, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    }
  ]
}

Key observations:

  • This is the deepest level (Level 3). No further nesting is allowed.
  • min_selections: 0 -- sauces are optional.
  • max_selections: 2 -- the customer can pick up to 2 sauces.
  • A1 is free, Peppercorn Ranch adds $0.50, Chimichurri adds $0.75.
  • All modifiers have modifier_groups: [] -- guaranteed at Level 3.

Level 1: Toppings (Multi-Select, Optional)

The customer can pick up to 5 toppings. This is a flat group with no nesting.

{
  "id": "mg000001-0000-0000-0000-000000000003",
  "name": "Toppings",
  "min_selections": 0,
  "max_selections": 5,
  "allows_duplicates": false,
  "modifiers": [
    {
      "id": "m0000005-0000-0000-0000-000000000001",
      "name": "Lettuce",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000005-0000-0000-0000-000000000002",
      "name": "Tomato",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000005-0000-0000-0000-000000000003",
      "name": "Pickles",
      "price_adjustment": { "amount": 0, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000005-0000-0000-0000-000000000004",
      "name": "Jalapenos",
      "price_adjustment": { "amount": 50, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    },
    {
      "id": "m0000005-0000-0000-0000-000000000005",
      "name": "Avocado",
      "price_adjustment": { "amount": 150, "currency": "USD" },
      "is_default": false,
      "modifier_groups": []
    }
  ]
}

Key observations:

  • min_selections: 0 -- toppings are entirely optional.
  • Most toppings are free. Jalapenos add $0.50. Avocado adds $1.50.
  • No nesting -- all modifiers are leaf-level.

Complete Nesting Visualization

Build Your Own Sub Sandwich ($8.99 base)
|
|-- Bread Choice [pick exactly 1]
|     |-- White (+$0.00) *default*
|     |-- Wheat (+$0.00)
|     |-- Italian Herb & Cheese (+$0.75)
|
|-- Protein [pick 1 to 2]
|     |-- Turkey (+$0.00)
|     |-- Ham (+$0.00)
|     |-- Steak (+$2.00)
|           |
|           |-- Steak Preparation [pick exactly 1]
|                 |-- Rare (+$0.00)
|                 |-- Medium (+$0.00) *default*
|                 |     |
|                 |     |-- Sauce [pick 0 to 2]
|                 |           |-- A1 Steak Sauce (+$0.00)
|                 |           |-- Peppercorn Ranch (+$0.50)
|                 |           |-- Chimichurri (+$0.75)
|                 |
|                 |-- Well Done (+$0.00)
|
|-- Toppings [pick 0 to 5]
      |-- Lettuce (+$0.00)
      |-- Tomato (+$0.00)
      |-- Pickles (+$0.00)
      |-- Jalapenos (+$0.50)
      |-- Avocado (+$1.50)

Pricing

The total price of a customized item is the base price plus all selected modifier price adjustments. Modifiers at every level contribute to the total.

Formula:

total_price = base_price + sum(all selected modifier price_adjustments)

All monetary values are in integer cents (USD).

Example Calculation

A customer orders a sub with:

  • Italian Herb & Cheese bread (+$0.75)
  • Steak protein (+$2.00)
  • Medium preparation (+$0.00)
  • Chimichurri sauce (+$0.75)
  • Lettuce (+$0.00)
  • Avocado (+$1.50)
Base price:                $8.99  (899 cents)
+ Italian Herb & Cheese:   $0.75  ( 75 cents)
+ Steak:                   $2.00  (200 cents)
+ Medium:                  $0.00  (  0 cents)
+ Chimichurri:             $0.75  ( 75 cents)
+ Lettuce:                 $0.00  (  0 cents)
+ Avocado:                 $1.50  (150 cents)
----------------------------------------------
Total:                    $13.99  (1399 cents)

Pseudocode:

def calculate_item_price(item, selected_modifiers):
    """
    Calculate total price for an item with selected modifiers.

    item: MenuItem object
    selected_modifiers: list of Modifier objects (all levels flattened)
    """
    total = item["base_price"]["amount"]  # Start with base price in cents

    for modifier in selected_modifiers:
        total += modifier["price_adjustment"]["amount"]

    return total

Note: This is a client-side estimate. Use the server-side price calculation endpoint (Phase 2) for the authoritative total, which includes tax and discounts.

Default Modifiers

Modifiers with is_default: true are pre-selected in the UI. Defaults provide a reasonable starting configuration without requiring the customer to make every choice.

Behavior rules:

  1. Default modifiers count toward min_selections. If min_selections: 1 and a modifier is is_default: true, the requirement is met without customer action.
  2. A group can have at most max_selections defaults. (If a group allows 1 selection and 2 modifiers are marked default, use only the first.)
  3. Default modifiers contribute to the price. If a default modifier has a non-zero price_adjustment, it is included in the price from the start.
  4. The customer can deselect defaults and choose alternatives.

In the worked example:

  • Bread Choice: "White" is default (customer starts with White bread).
  • Steak Preparation: "Medium" is default (if customer picks Steak, Medium is pre-selected).
  • No defaults in Protein, Toppings, or Sauce groups.

Validation Rules

Before submitting modifier selections to the cart, validate them client-side to provide immediate feedback.

Validation pseudocode

def validate_modifier_group(group, selections):
    """
    Validate modifier selections for a group.

    group: ModifierGroup object
    selections: list of selected modifier IDs for this group

    Returns: (is_valid, error_message)
    """
    count = len(selections)
    min_sel = group["min_selections"]
    max_sel = group["max_selections"]

    # Check minimum selections
    if count < min_sel:
        return (False, f"{group['name']}: select at least {min_sel} (got {count})")

    # Check maximum selections
    if count > max_sel:
        return (False, f"{group['name']}: select at most {max_sel} (got {count})")

    # Check for duplicates
    if not group["allows_duplicates"] and len(set(selections)) != count:
        return (False, f"{group['name']}: duplicate selections not allowed")

    # Check that selected IDs exist in the group
    valid_ids = {m["id"] for m in group["modifiers"]}
    for sel in selections:
        if sel not in valid_ids:
            return (False, f"{group['name']}: modifier {sel} not found in group")

    return (True, None)


def validate_item_modifiers(item, all_selections):
    """
    Validate all modifier selections for a menu item recursively.

    item: MenuItem object
    all_selections: dict mapping modifier_group_id -> list of selected modifier IDs

    Returns: list of (is_valid, error_message) tuples
    """
    errors = []

    def validate_groups(groups):
        for group in groups:
            group_id = group["id"]
            selections = all_selections.get(group_id, [])

            is_valid, error = validate_modifier_group(group, selections)
            if not is_valid:
                errors.append(error)

            # Recursively validate nested groups for selected modifiers
            for modifier in group["modifiers"]:
                if modifier["id"] in selections and modifier.get("modifier_groups"):
                    validate_groups(modifier["modifier_groups"])

    validate_groups(item.get("modifier_groups", []))
    return errors

Validation rules summary

RuleCheckError
Minimum selectionslen(selections) >= min_selections"Select at least N"
Maximum selectionslen(selections) <= max_selections"Select at most N"
No duplicatesIf allows_duplicates: false, all IDs must be unique"Duplicate selections not allowed"
Valid modifier IDsAll selected IDs exist in the group's modifiers array"Modifier not found"
Nested groupsIf a selected modifier has nested groups, validate those too(recursive)
Level 3 is terminalModifiers at Level 3 will never have modifier_groupsN/A (enforced by API)

UI Rendering Tips

Display each nesting level progressively

Do not show all 3 levels at once. Reveal nested modifier groups only after the parent modifier is selected:

  1. Show Level 1 groups when the customer opens the item.
  2. When the customer selects a modifier with nested groups, expand Level 2 below it.
  3. When the customer selects a Level 2 modifier with nested groups, expand Level 3.

Render group types appropriately

PatternminmaxUI Element
Exactly 111Radio buttons
Up to 101Radio buttons + "None" option
Multiple0+2+Checkboxes
With duplicatesanyanyStepper/counter per modifier

Show price adjustments inline

Display the price adjustment next to each modifier name so the customer knows the cost before selecting:

Bread Choice (pick 1):
  (*) White
  ( ) Wheat
  ( ) Italian Herb & Cheese (+$0.75)

Modifiers with price_adjustment.amount: 0 should show nothing or "Included" -- not "+$0.00."

Pre-select defaults

When rendering a modifier group, check each modifier's is_default field. Pre-select those modifiers in the UI so the customer starts with a valid configuration.

Show required vs. optional

Mark groups with min_selections >= 1 as "Required" in the UI. Groups with min_selections: 0 are optional and should be visually distinct.

Bread Choice (Required - pick 1):
  ...

Toppings (Optional - up to 5):
  ...

Next Steps