Gett Developer Portal
  • Welcome
  • Distribution Partners
  • Brand Partners
  • Commerce Partners
  • Ecosystem Partners
  • Errors
  • API Reference
Documentation
  • Get Started
  • Marketfront SDK
  • API Reference
Resources
  • Payments
Company
  • Gett
  • Terms of Service
  • Privacy Policy

Copyright 2026 Gett. All rights reserved.

Marketfront SDK
Marketfront API
    Getting StartedConventionsOrder LifecycleCatalogSet
    API Reference
Marketfront AI
Shared Guides
powered by Zuplo
Marketfront API

CatalogSet Structure

A CatalogSet is the complete menu for a store — every catalog, section, item, and modifier group in a single response. This guide covers the data model, business rules, and pricing logic you need to render menus and build carts correctly.

Structure Overview

CatalogSets use a normalized dictionary structure — flat id-to-object maps rather than deep nesting.

Why dictionaries?

  • O(1) lookups — Find any entity instantly by ID
  • No duplication — An item referenced by multiple sections exists once
  • Flexible relationships — Modifiers reference items; items reference modifier groups; the graph can nest arbitrarily

Each dictionary key matches the entity's own ID property (e.g., catalogs["abc"].catalogId === "abc").

Navigating the Graph

Start from a catalog and follow ID references through the dictionaries:

Code
catalogs[catalogId] → sectionIds → sections[sectionId] → sectionIds → sections[sectionId] (nested sub-sections) → itemIds → items[itemId] → modifierGroupIds → modifierGroups[modifierGroupId] → itemIds → items[itemId] (modifiers are items!)

Build a section tree by starting with a catalog's sectionIds and recursively following sections[id].sectionIds. Use a visited set during recursion to guard against cycles.

Items and sub-sections are separate ordered lists within a section. There is no merged interleaved order — render items first, then child sections (or vice versa, per your UX).

Catalogs & Availability

A store can have multiple catalogs for different dayparts — Breakfast, Lunch, Dinner, Late Night.

Each catalog has an optional availability array of time windows:

FieldTypeDescription
dayOfWeekstring"Sunday" through "Saturday"
startstringStart time in HH:mm:ss format
endstringEnd time in HH:mm:ss format

Rules:

  • Null or empty availability = the catalog is never open. To declare 24/7 availability, provide an explicit window for each day-of-week. The same fail-closed semantic applies to Store.availability.
  • 00:00:00 end time = end of day (not midnight-to-midnight)
  • Overnight windows must be split into two entries:
Code
// Friday 10 PM - Saturday 2 AM: [ { "dayOfWeek": "Friday", "start": "22:00:00", "end": "00:00:00" }, { "dayOfWeek": "Saturday", "start": "00:00:00", "end": "02:00:00" } ]

Sections

Sections organize items into categories (e.g., "Appetizers", "Entrees") and support nesting.

FieldDescription
itemIdsOrdered list of item IDs in this section
sectionIdsChild section IDs for hierarchies (e.g., Food → Hot Food → Pizza)

Key behaviors:

  • A section can contain both items and nested sub-sections
  • The same item can appear in multiple sections (shared items)
  • Ordering within itemIds and sectionIds is meaningful — use it for display order

Items

Items serve a dual role: they are both menu products customers can order and modifier options within modifier groups.

FieldDescription
basePricePrice before modifiers. For modifier options, this is the upcharge (e.g., Large = +$1.00). Normally the advertised price — but see Included extras, where it is a synthetic base and may be negative
startingAtThe cheapest orderable configuration, shown as the headline "from $X". It already includes the cheapest required modifier, so never add modifiers on top of it. It is also a floor on the line's unit price (see Price Calculation)
compareAtOriginal/marketplace price for showing savings (strikethrough pricing)
isAvailableWhether item can currently be ordered
modifierGroupIdsCustomization groups attached to this item

Modifiers are items

A "Small" size option and a "Margherita Pizza" are both Item objects. The difference is context — items in modifierGroups[id].itemIds are modifier options; items in sections[id].itemIds are menu products. Some items may be both.

Modifier Groups

Modifier groups define customization options for an item — sizes, toppings, sides, etc.

Selection Rules

The minimumAllowed and maximumAllowed fields define what customers must select:

MinMaxMeaningExample
01Optional single choice"Add a side?"
11Required single choice (radio)"Choose size: S / M / L"
0NOptional, up to N"Add up to 5 toppings"
1NRequired, 1 to N"Pick 1-3 sauces"
NNExactly N required"Pick 2 sides"

Validation rules:

  • minimumAllowed >= 0
  • maximumAllowed >= minimumAllowed
  • When enableDuplicateItems is false, itemIds.length >= minimumAllowed (enough unique options to satisfy the minimum)

Duplicate Selections

Set enableDuplicateItems: true to allow the same option to be selected multiple times. This is essential for scenarios like:

Code
"Baker's Dozen" (13 bagels): ModifierGroup: "Choose Your Bagels" Items: [Plain, Sesame, Everything, Cinnamon Raisin] minimumAllowed: 13 maximumAllowed: 13 enableDuplicateItems: true Valid selection: 5× Plain, 4× Everything, 4× Cinnamon Raisin = 13

Without enableDuplicateItems, you would need 13 individual modifier groups for the Baker's Dozen item.

Default Items

The defaultItems array pre-selects modifier options (e.g., "Comes with lettuce and tomato"):

Code
{ "defaultItems": [ { "itemId": "lettuce-id", "quantity": 1 }, { "itemId": "tomato-id", "quantity": 1 } ] }

Rules:

  • Each itemId must exist in the modifier group's itemIds
  • Total quantity across all defaults must not exceed maximumAllowed

A default is priced like any other selection. It contributes its basePrice to the line exactly as a customer-chosen option does — there is no "free because it's default" rule in cart math.

The cart you send is the order, so a default you omit is one the kitchen won't make. Model defaults as real selections the customer can remove, not as decoration on the option list. Where a merchant lets a removed default pay for something else, the catalog encodes that for you — see Included extras and the substitution credit.

Nested Modifiers

Because modifier options are items, they can have their own modifierGroupIds — enabling multi-level customization:

Code
Pizza → "Crust" modifier group → "Stuffed Crust" item ($3.00) → "Choose Stuffing" modifier group → "Cheese" item ($0.00) → "Garlic Butter" item ($0.50)

Most menus stay 1-2 levels deep, but the structure supports arbitrary nesting.

Variant Size Groups

Multi-variant items (Small / Medium / Large; Thin Crust / Stuffed Crust; etc.) surface as a parent item plus a required single-select modifier group containing one item per variant:

Code
Milkshake (parent) basePrice: $0.00 ← parent contributes nothing startingAt: $8.00 ← display: "from $8.00" (cheapest variant) → "Size" modifier group (minimumAllowed = 1, maximumAllowed = 1) → "Small Milkshake" basePrice: $8.00 (absolute) → "Medium Milkshake" basePrice: $9.00 (absolute) → "Large Milkshake" basePrice: $10.00 (absolute)

Conventions:

  • parent.basePrice = 0. The parent contributes nothing to the cart total — the absolute price comes entirely from the selected variant.
  • parent.startingAt carries the cheapest variant's price so menu rendering can show "from $8.00".
  • Each variant child carries its absolute price as basePrice. This is the same value the partner echoes back when the user selects that variant.
  • The group's minimumAllowed and maximumAllowed are both 1 (radio-style required pick).
  • The variant child is a regular Item and may carry its own nested modifierGroupIds (per-variant toppings, for example).

Cart math is the standard additive model (no special-casing required): parent.basePrice (0) + the selected variant's basePrice is the absolute price of the chosen variant. Picking Medium Milkshake gives $0.00 + $9.00 = $9.00. This matches the partner-side semantics where a variant selector replaces — rather than adds on top of — the parent's price.

Tiered Pricing

Modifier groups can use tiered pricing instead of item base prices. When tieredPricing is present, it overrides basePrice for selections in that group.

Each tier has an offset (0-based selection index) and a price:

Code
{ "name": "Select Pasta Dishes", "minimumAllowed": 2, "maximumAllowed": 6, "enableDuplicateItems": true, "tieredPricing": [ { "offset": 0, "price": 0.00 }, { "offset": 2, "price": 8.00 }, { "offset": 4, "price": 7.00 } ] }

How to resolve the tier for the k-th selection (0-based): use the tier with the greatest offset that is ≤ k.

SelectionkApplicable TierPrice
1st dish0offset 0$0.00
2nd dish1offset 0$0.00
3rd dish2offset 2$8.00
4th dish3offset 2$8.00
5th dish4offset 4$7.00
6th dish5offset 4$7.00

Tier rules:

  • Offsets must be non-negative and unique
  • Tiers must be in ascending offset order
  • When tieredPricing is null or empty, fall back to each item's basePrice

What tiered pricing is not. It is a per-selection price table, and nothing more: it answers "what does the k-th selection in this group cost". It cannot see what the customer removed, and it cannot span groups. Use it for a genuinely positional allowance ("the first 2 toppings are free, whatever else you do"). For an allowance funded by giving up pre-selected defaults, use the substitution credit instead — a tier will silently over-credit there.

Price Calculation

Line item price = (base price + all modifier prices, floored at startingAt) × quantity. Modifier costs apply per-unit of the parent line — a qty-3 burger with a qty-1 cheese modifier costs 3 × ($10 + $1.50) = $34.50. Modifier groups with tieredPricing use the per-selection tier price in place of each selection's basePrice; nested modifiers below a tiered selection still contribute their own costs.

Headline "from" price vs running total

These are two different numbers — keep them separate:

  • Headline / "from $X" (startingAt) is the cheapest orderable configuration: basePrice plus the cheapest option of each required group. Use it on cards and as the modal's opening price.
  • Running / cart total is computed by the algorithm below from raw basePrice (basePrice + Σ selected modifier basePrice), then clamped up to startingAt. Never compute it as startingAt + modifiers — startingAt already folds in the cheapest required modifier, so adding modifiers on top double-counts.

For an ordinary item the clamp never fires: by definition nothing prices below the cheapest orderable configuration. It exists so a merchant can give value back without ever refunding below the advertised price — see Included extras below.

Worked example — front-loaded "sundae" (parent basePrice $0, startingAt $10):

Code
Sundae (parent) basePrice: $0.00 startingAt: $10.00 ← headline "from $10.00" → "Size" group (required, min = max = 1) → Small basePrice: $10.00 (display delta: $0 — the cheapest, absorbed into the headline) → Medium basePrice: $12.00 (display delta: +$2) → Large basePrice: $14.00 (display delta: +$4)
User picksRunning total (from raw)✗ Wrong (startingAt + modifier)
Small$0 + $10 = $10$10 + $10 = $20
Medium$0 + $12 = $12$10 + $12 = $22
Large$0 + $14 = $14$10 + $14 = $24

The per-option display delta ($0 / +$2 / +$4) is purely a UI label — subtract the cheapest required option's price so the cheapest reads $0. The cart total is still computed from raw basePrice by the algorithm below, so the two never disagree. (The startingAt floor of $10 is satisfied exactly by the cheapest pick and exceeded by the others, so it changes nothing here — which is the normal case.)

Included extras and the substitution credit

Some merchants include a set of extras in the advertised price and let you swap rather than only add: give up an included topping and its value pays for one you'd rather have. This is expressed with the two fields you already have, so your cart math does not change:

  • basePrice becomes a synthetic base — the advertised price minus the total value of the item's pre-selected defaults. It may be negative.
  • startingAt carries the advertised price, and acts as the floor.
  • Each default carries its real price, like any other option.

Plain addition under the floor then reproduces the merchant's rule exactly:

Code
charged = max(advertised, syntheticBase + Σ every selected modifier)

Worked example — The Heartland Sub. Advertised $8.01, with seven included toppings worth $8.25 together. So basePrice = 8.01 − 8.25 = −$0.24 and startingAt = $8.01:

CustomerbasePrice + Σ modifiersChargedWhy
Changes nothing−0.24 + 8.25 = $8.01$8.01The advertised price, reproduced by arithmetic
Keeps all 7, adds Bacon ($1.25)−0.24 + 8.25 + 1.25 = $9.26$9.26Nothing was given up, so the extra is paid in full
Drops Chips ($1.50), adds Bacon ($1.25)−0.24 + 6.75 + 1.25 = $7.76$8.01The $1.50 covers the $1.25; the floor stops the remaining $0.25 becoming a refund
Drops all 7, adds nothing−0.24 + 0 = −$0.24$8.01Removing extras alone never reduces the price

Both directions are pinned in the shared fixture suite as substitution-credit-floor.json and substitution-credit-no-clamp.json.

Why this is not tiered pricing

The obvious-looking encoding — a "first k free" tier on the topping group — is wrong, and wrong in the direction that costs the merchant money. A tier is a per-selection constant, so it also makes the added topping free for the customer who kept every default and should have paid $9.26 (row 2 above). The credit is a function of what was removed, which no per-selection table can see.

Labelling options in a configurator

The catalog price of an option is not what it will cost, and this is where that bites hardest. With the allowance in play, a topping listed at $1.25 may add nothing to the total — so a configurator that labels options straight from basePrice advertises a price its own running total then ignores.

Label each option with its marginal cost instead: price the line with the option, price it without, and show the difference. @gett-co/ordering-core exports this as modifierOptionMarginalPrice(optionId, groupId, lineItem, catalogSet). It returns the option's basePrice on an ordinary item, so adopting it changes nothing for menus without an allowance — and it also gets tiered pricing right, where the k-th selection is priced by its tier rather than by the option.

Pass the whole item's line, not the group's: credit is earned in one group and spent in another, so no group can answer this on its own.

Two consequences to design for:

  • A menu item may carry a negative basePrice. That is only legal alongside a floor, and the two are validated together. Render startingAt — not raw basePrice — anywhere you show a price outside a fully built line, or you will display the synthetic figure.
  • A floor and a negative-priced modifier on the same item are mutually exclusive. A genuine deselect credit (e.g. "No Drink −$0.75") is supposed to price below the base, and the floor would swallow it. No catalog does both.

The reference implementation is shipped as a stateless helper in @gett-co/ordering-core (cartMath.ts) and mirrored byte-for-byte in the .NET internal provider. Both are driven by a shared JSON fixture suite — partners are free to reuse the TypeScript module directly or port the algorithm into their own client:

If you ported an earlier version of this algorithm

lineItemPrice gained the startingAt floor, split out as lineItemUnitPrice below. It is a two-line change and it is additive — every result the old code produced for an item without a floor is unchanged. Re-porting matters only if you serve merchants who include extras in the advertised price; without the clamp those lines quote below what the merchant charges and validation rejects the order.

Code
// Per-unit price of a built line. The floor applies per unit, BEFORE the quantity // multiplier — a floored $8.01 sandwich at qty 3 is $24.03, not a $8.01 cart. export function lineItemUnitPrice(li: LineItem, catalogSet: CatalogSet): number { const item = lookupItem(catalogSet, li.itemId); let unitPrice = item.basePrice ?? 0; for (const mg of li.modifierGroups ?? []) { unitPrice += modifierGroupPrice(mg, catalogSet); } const floor = item.startingAt; return floor != null && unitPrice < floor ? floor : unitPrice; } export function lineItemPrice(li: LineItem, catalogSet: CatalogSet): number { return lineItemUnitPrice(li, catalogSet) * li.quantity; } function modifierGroupPrice(mg: CartModifierGroup, catalogSet: CatalogSet): number { const groupDef = lookupModifierGroup(catalogSet, mg.modifierGroupId); const selections = mg.lineItems ?? []; let total = 0; if (groupDef.tieredPricing && groupDef.tieredPricing.length > 0) { const tiers = sortedTiers(groupDef.tieredPricing); let k = 0; for (const sel of selections) { let nestedCost = 0; for (const nestedMg of sel.modifierGroups ?? []) { nestedCost += modifierGroupPrice(nestedMg, catalogSet); } for (let i = 0; i < sel.quantity; i++) { total += tierPriceAt(tiers, k) + nestedCost; k++; } } } else { for (const sel of selections) { const selItem = lookupItem(catalogSet, sel.itemId); let unitPrice = selItem.basePrice ?? 0; for (const nestedMg of sel.modifierGroups ?? []) { unitPrice += modifierGroupPrice(nestedMg, catalogSet); } total += unitPrice * sel.quantity; } } return total; }

tierPriceAt(tiers, k) returns the tier with the greatest offset that is <= k.

Note that modifierGroupPrice does not floor a modifier's own contribution. The clamp applies exactly once, at the top of the line — a nested option's startingAt is a display value in that position, not a floor.

Worked Examples

Burger with cheese (qty > 1)

LineCalculationSubtotal
3× Burger ($10) + 1× Cheese modifier ($1.50)3 × ($10 + $1.50)$34.50

The cheese cost is added to the unit price before multiplying by 3 — not added flat after.

Pizza with nested modifiers

LineCalculationSubtotal
1× Pizza ($12) + Stuffed Crust ($3) + Garlic Butter ($0.50)1 × ($12 + $3 + $0.50)$15.50

Nested modifier costs from the Crust modifier group accumulate into the unit price before the outer quantity is applied.

Pasta platter with tiered pricing (3 dishes)

Using the tiered group from the example above (1st–2nd dish free, 3rd–4th at $8.00):

SelectionkTier Price
1st dish0$0.00
2nd dish1$0.00
3rd dish2$8.00

Subtotal = $0 + $0 + $8 = $8.00. Any nested modifiers on each dish are added to that dish's tier price, not to the parent item.

Item with included extras — see the Heartland Sub table, the only case where the startingAt floor changes a total.

The Amounts block on a validated order (taxes, fees, promotions, total) remains server-authoritative and is returned by the validateOrder endpoint — the helper above only computes the cart subtotal you can show before validating.

Caching

CatalogSets are immutable — once created, a catalogSetId always returns the same data. Cache aggressively:

  • Use catalogSetId as the cache key
  • The getCatalogSet endpoint supports ETag caching — include If-None-Match with the ETag from a previous response to receive a 304 Not Modified when the catalog hasn't changed
  • When a store updates its menu, a new CatalogSet is created with a new catalogSetId

Related

  • API Reference — Endpoint and schema documentation
  • Order Lifecycle — How orders are validated against the catalog
  • Schemas — Type definitions and data models
Order LifecycleMarketfront AI
On this page
  • Structure Overview
  • Navigating the Graph
  • Catalogs & Availability
  • Sections
  • Items
  • Modifier Groups
    • Selection Rules
    • Duplicate Selections
    • Default Items
    • Nested Modifiers
    • Variant Size Groups
  • Tiered Pricing
  • Price Calculation
    • Headline "from" price vs running total
    • Included extras and the substitution credit
    • Worked Examples
  • Caching
  • Related
JSON
JSON
JSON
TypeScript