Catalog setup

Build the catalog a coffee shop needs: a croissant, a latte in two sizes, a milk choice with its own price, and a breakfast combo. Then put them on an order. Flint prices every line, choice, and combo from the catalog, so your code never adds 75 cents for oat milk.

The same catalog serves online stores and restaurants alike. Swap the latte for a t-shirt in three sizes and the milk choice for gift wrap, and every call below stays the same.

The API reference covers every field on products and variants, categories, modifiers, and bundles.

The model in one paragraph#

A product is what you sell, by name: a latte. It has no price or SKU. Its variants are the units you sell, each with its own price and SKU: the 12 oz latte and the 16 oz latte. Options label the variants: Size, with the values 12 oz and 16 oz. Categories group products and bundles for filtering and for promotion and return targeting. A modifier set holds the choices a buyer makes at purchase time, such as oat milk or a name on the cup. A bundle sells several variants as one line at its own price. An order line names a variant_id or a bundle_id, never a product_id, and Flint copies the name, price, SKU, and tax settings from the catalog onto the line.

ResourceRoutesWhat you use it for
Products, variants, options/v1/products, /v1/products/{product_id}/variants, /v1/products/{product_id}/optionsWhat you sell and its prices
Categories/v1/categoriesGrouping, filtering, and targeting
Modifier groups and sets/v1/modifier-groups, /v1/modifier-setsChoices made at purchase time
Bundles/v1/bundlesSeveral variants sold as one line

Products, variants, and options need the commerce.products scopes. Categories and modifiers need commerce.catalog. Bundles need commerce.bundles. Each scope has a .read and a .write form.

1. Create a product with one price#

A croissant comes in one form, so it is a simple product with a default_variant. The price, SKU, and tax settings go on the variant.

cURL
curl -X POST https://api.withflintpay.com/v1/products \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: product-croissant" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Butter croissant",
    "product_type": "physical",
    "categories": ["Pastries"],
    "default_variant": {
      "sku": "PAS-CROISSANT",
      "unit_price_money": { "amount": 450, "currency": "USD" },
      "line_item_tax_category": "prepared_food"
    }
  }'
Response
{
  "data": {
    "product_id": "prod_1kmn0aExample",
    "name": "Butter croissant",
    "product_type": "physical",
    "status": "active",
    "version": 1,
    "default_variant_id": "var_1kmn0aExample",
    "variant_count": 1,
    "available_for_sale": true,
    "categories": [
      { "category_id": "ctg_1kmn0aExample", "handle": "pastries", "name": "Pastries" }
    ]
  }
}

The response is abridged. default_variant_id is the ID you sell. Sending unit_price_money, sku, taxable, or line_item_tax_category on the product itself returns UNSUPPORTED_PRODUCT_FIELD.

product_type is physical, service, fee, digital, or gift_card. A physical variant created without delivery_profile_id gets your default delivery profile. Variants do not track stock unless you link an inventory item. The Inventory guide covers that.

Gift card variants require USD and are non-taxable. Omit line_item_tax_category, inventory_item_id, inventory_item, modifier_set_id, and delivery_profile_id. They do not require delivery, cannot be included in bundles or subscriptions, and are excluded from ordinary cart discounts. Existing gift card value can pay for other goods in the same order, but cannot pay for a new gift card purchase. Create a separate product to change to or from product_type: gift_card.

For a deliberate discounted offer, put the issued value in gift_card_configuration.face_value_money and the price paid in unit_price_money. For example, a $100 gift card sold for $90 uses:

JSON
{
  "unit_price_money": { "amount": 9000, "currency": "USD" },
  "gift_card_configuration": {
    "face_value_money": { "amount": 10000, "currency": "USD" },
    "price_mode": "discounted"
  }
}

Omitting the configuration creates a fixed denomination at the variant price. Optional custom_amount_bounds define minimum and maximum USD face values; the reference face value must fall within them. Face values and bounds cannot exceed 200000 cents per card. Custom prices use the configured price-to-face-value ratio with cent rounding. An update preserves the configuration when omitted and replaces it when provided. To change the price of a face_value variant, send the configuration with the new face_value_money too. Include expected_version to reject a concurrent change.

2. Create a product with sizes#

A latte comes in two sizes. Send options and variants together instead of default_variant. Flint never generates variants from options, so list every variant you sell. Each variant selects exactly one value for each option.

cURL
curl -X POST https://api.withflintpay.com/v1/products \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: product-latte" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Latte",
    "product_type": "physical",
    "categories": ["Espresso drinks"],
    "options": [{
      "name": "Size",
      "client_option_key": "size",
      "values": [
        { "value": "12 oz", "client_value_key": "12" },
        { "value": "16 oz", "client_value_key": "16" }
      ]
    }],
    "variants": [
      {
        "name": "12 oz",
        "sku": "ESP-LATTE-12",
        "unit_price_money": { "amount": 475, "currency": "USD" },
        "line_item_tax_category": "prepared_food",
        "selected_option_values": [{ "client_option_key": "size", "client_value_key": "12" }]
      },
      {
        "name": "16 oz",
        "sku": "ESP-LATTE-16",
        "unit_price_money": { "amount": 525, "currency": "USD" },
        "line_item_tax_category": "prepared_food",
        "selected_option_values": [{ "client_option_key": "size", "client_value_key": "16" }]
      }
    ]
  }'

The client keys exist only in this request. They let a new variant point at a new option value before either has an ID. Flint assigns opt_ and optv_ IDs to the options and values and returns them in the product's options array.

The product response has variant_count and price_range but not the variants themselves. List them to get their IDs:

cURL
curl https://api.withflintpay.com/v1/products/prod_1kmn0bExample/variants \
  -H "Authorization: Bearer YOUR_API_KEY"

Each variant returns selected_options with the option name and value, for example { "option_name": "Size", "value": "16 oz" }. An order line for this variant is named Latte - 16 oz.

The rules Flint enforces when you create a product like this:

  • Send default_variant or options with variants, never both. Mixing them returns INVALID_PRODUCT_CREATE_SHAPE.
  • Two variants cannot select the same combination of values.
  • All active variants of one product use the same currency.
  • A SKU is unique across every variant and bundle in the environment.

To add a size later, see Change the catalog later.

3. Group products with categories#

Step 1 and step 2 already created two categories. When a product or bundle write names a category that does not exist, Flint creates it and derives a lowercase, hyphenated handle from the name: Espresso drinks becomes espresso-drinks. A value that matches an existing handle or name reuses that category.

To choose the handle yourself, create the category first:

cURL
curl -X POST https://api.withflintpay.com/v1/categories \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: category-seasonal" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Seasonal", "handle": "seasonal" }'

A handle never changes, even when you rename the category, so use it anywhere a category is stored:

  • Filter the catalog with GET /v1/products?category_handle=espresso-drinks. Bundles accept the same filter.
  • Target a promotion or a return policy with category_handles.

To change a product's categories, send the complete categories array with the product's current version as expected_version. Send an empty array to clear them. A product or bundle can have up to 100 categories.

Deleting a category works only while no product, bundle, promotion, or return setting references it. Otherwise the request returns CATEGORY_REFERENCED. The category's assigned_product_count, assigned_bundle_count, and targeting_reference_count show what still points at it.

4. Add modifiers#

A buyer ordering a latte picks a milk, may add an extra shot, and gives a name for the cup. Modifiers have three layers:

  • A modifier group is one prompt with its own selection rules. A list group offers choices, and a text group takes free text.
  • A modifier is one choice in a list group, with an optional unit_price_delta_money and its own taxable and line_item_tax_category.
  • A modifier set puts groups in order and attaches to a product, variant, or bundle.

Create a reusable group#

Every espresso drink on the menu offers the same milk choice, so create it once as an independent group. min_selected and max_selected of 1 make it exactly one choice.

cURL
curl -X POST https://api.withflintpay.com/v1/modifier-groups \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: modifier-group-milk" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Milk",
    "modifier_group_type": "list",
    "min_selected": 1,
    "max_selected": 1,
    "modifiers": [
      { "name": "Whole milk", "position": 0 },
      { "name": "Oat milk", "position": 1, "unit_price_delta_money": { "amount": 75, "currency": "USD" } }
    ]
  }'

The response returns the group's mg_ ID and each choice's mod_ ID. Group rules must be satisfiable: a list group needs enough active choices to meet min_selected, and quantity limits require "allow_quantities": true.

Create the set#

The set combines the Milk group with two groups that belong only to this set. Use "source": "existing" for a group you created separately and "source": "inline" to create a group inside the set.

cURL
curl -X POST https://api.withflintpay.com/v1/modifier-sets \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: modifier-set-espresso" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Espresso drink choices",
    "modifier_groups": [
      { "source": "existing", "modifier_group_id": "mg_1kmn0aExample", "position": 0 },
      {
        "source": "inline",
        "position": 1,
        "modifier_group": {
          "name": "Extra shot",
          "min_selected": 0,
          "max_selected": 1,
          "allow_quantities": true,
          "max_quantity": 3,
          "modifiers": [
            { "name": "Extra shot", "unit_price_delta_money": { "amount": 100, "currency": "USD" } }
          ]
        }
      },
      {
        "source": "inline",
        "position": 2,
        "modifier_group": {
          "name": "Name on cup",
          "modifier_group_type": "text",
          "text": { "required": false, "max_length": 20 }
        }
      }
    ]
  }'

An inline group is not available through /v1/modifier-groups. Edit it by sending the set's complete modifier_groups array to PATCH /v1/modifier-sets/{modifier_set_id} with expected_version. Include the IDs of the groups and choices you keep. A set can also override a group's display_name, min_selected, max_selected, and required for this set only, and modifier_overrides can hide a choice or change its price.

Attach the set#

Attach the set to the latte product. Every latte variant then offers these choices.

cURL
curl -X PATCH https://api.withflintpay.com/v1/products/prod_1kmn0bExample \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "modifier_set_id": "ms_1kmn0aExample" }'

A catalog item holds one modifier set, and you can set modifier_set_id on a product, a variant, or a bundle. Send null to remove it. When Flint prices a line, it uses:

  1. On a bundle line, the bundle's set only. A bundle does not inherit its components' sets.
  2. On a variant line, the variant's set if it has one, and otherwise the product's set.

Read a product with expand=modifier_set to get the set, its groups, and their choices in one response. A priced choice uses the currency of the line it is sold on. A mismatch returns MODIFIER_CURRENCY_MISMATCH.

5. Create a bundle#

The breakfast combo sells a croissant and a 12 oz latte for $7.50 instead of $9.25. The bundle has its own price, SKU, and tax settings, and each component names a variant and a quantity.

cURL
curl -X POST https://api.withflintpay.com/v1/bundles \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: bundle-breakfast-combo" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Breakfast combo",
    "sku": "CMB-BREAKFAST",
    "status": "active",
    "unit_price_money": { "amount": 750, "currency": "USD" },
    "line_item_tax_category": "prepared_food",
    "categories": ["Combos"],
    "components": [
      { "variant_id": "var_1kmn0aExample", "quantity": 1, "position": 0 },
      { "variant_id": "var_1kmn0bExample", "quantity": 1, "position": 1 }
    ]
  }'

Warning: Bundles start inactive

Products are active by default, but a bundle created without status is inactive and cannot be sold. Send "status": "active" on create, or activate it later with PATCH /v1/bundles/{bundle_id}.

An active bundle needs at least one component, and every component's product and variant must be active. A physical component also needs an active delivery profile, which it copies from its variant unless you send delivery_profile_id on the component.

available_for_sale on a bundle means every component can be sold. It does not guarantee stock. Flint checks and holds each tracked component's stock when the order claims it.

The latte in this combo gets no milk choice, because a bundle line uses only the bundle's own modifier set. To offer milk in the combo, set modifier_set_id on the bundle.

6. Sell it#

Create an order that names catalog items. The first line is two 16 oz oat milk lattes with an extra shot and a name. The second is the combo.

cURL
curl -X POST https://api.withflintpay.com/v1/orders \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: order-counter-0412" \
  -H "Content-Type: application/json" \
  -d '{
    "line_items": [
      {
        "variant_id": "var_1kmn0cExample",
        "quantity": 2,
        "modifiers": [
          { "modifier_id": "mod_1kmn0bExample" },
          { "modifier_id": "mod_1kmn0cExample", "quantity": 1 },
          { "text": { "modifier_group_id": "mg_1kmn0cExample", "value": "Sam" } }
        ]
      },
      { "bundle_id": "bun_1kmn0aExample", "quantity": 1 }
    ]
  }'

For a list choice, send its modifier_id, plus quantity when the group allows quantities. For a text group, send text with the group's modifier_group_id and the value. Flint resolves the rest:

Response
{
  "data": {
    "order_id": "ord_1kmn0aExample",
    "line_items": [
      {
        "source_type": "variant",
        "variant_id": "var_1kmn0cExample",
        "name": "Latte - 16 oz",
        "sku": "ESP-LATTE-16",
        "quantity": 2,
        "unit_price_money": { "amount": 525, "currency": "USD" },
        "base_subtotal_money": { "amount": 1050, "currency": "USD" },
        "modifiers": [
          {
            "modifier_group_name": "Milk",
            "name": "Oat milk",
            "quantity": 1,
            "unit_price_delta_money": { "amount": 75, "currency": "USD" },
            "total_money": { "amount": 150, "currency": "USD" }
          },
          {
            "modifier_group_name": "Extra shot",
            "name": "Extra shot",
            "quantity": 1,
            "unit_price_delta_money": { "amount": 100, "currency": "USD" },
            "total_money": { "amount": 200, "currency": "USD" }
          },
          {
            "modifier_group_name": "Name on cup",
            "name": "Name on cup",
            "text_value": "Sam",
            "quantity": 1,
            "unit_price_delta_money": { "amount": 0, "currency": "USD" },
            "total_money": { "amount": 0, "currency": "USD" }
          }
        ],
        "modifier_total_money": { "amount": 350, "currency": "USD" },
        "subtotal_money": { "amount": 1400, "currency": "USD" }
      },
      {
        "source_type": "bundle",
        "bundle_id": "bun_1kmn0aExample",
        "name": "Breakfast combo",
        "quantity": 1,
        "unit_price_money": { "amount": 750, "currency": "USD" },
        "subtotal_money": { "amount": 750, "currency": "USD" }
      }
    ]
  }
}

The response is abridged. A choice's total_money is its price change times its quantity times the line quantity, so two lattes with oat milk add $1.50. subtotal_money is the base subtotal plus modifier_total_money. The bundle line also returns bundle_components so a kitchen ticket or packing slip can list what goes in the combo.

Flint enforces each group's rules on the order. Leaving out the milk returns MODIFIER_GROUP_REQUIRED, and picking two milks returns MODIFIER_SELECTION_LIMIT_EXCEEDED. Modifiers work only on lines whose variant, product, or bundle has a modifier set.

Flint copies the catalog onto the line when the order is created. Catalog lines reject a caller-supplied name, unit_price_money, or tax setting with CATALOG_LINE_ITEM_FIELDS_READ_ONLY. For an item that is not in the catalog, send an ad hoc line with name, unit_price_money, and a fulfillment requirement instead of a catalog ID. Ad hoc lines cannot carry modifiers.

Payment links accept the same variant_id and bundle_id line sources, and a checkout session collects payment for an order like this one. To change a line's choices after the order exists, see Replace a line item's selections.

Change the catalog later#

Most updates are sparse: send only the fields you are changing. Some fields are complete lists, and Flint replaces the whole list in one write:

ListWhere
options and their valuesPATCH /v1/products/{product_id}
categoriesProduct and bundle PATCH
imagesProduct, variant, and bundle PATCH
componentsPATCH /v1/bundles/{bundle_id}
modifiersPATCH /v1/modifier-groups/{modifier_group_id}
modifier_groupsPATCH /v1/modifier-sets/{modifier_set_id}

A replacement requires the version you last read as expected_version. Include the ID of every member you keep, add new members without an ID, and leave out a member to retire it. If someone else changed the resource since you read it, the write returns a conflict such as CONCURRENT_MODIFICATION or MODIFIER_GROUP_CHANGED. Read it again and retry with the new version.

For example, to add a 20 oz size, send the complete options array with the IDs you already have and the new value without one:

JSON
{
  "expected_version": 2,
  "options": [{
    "option_id": "opt_1kmn0aExample",
    "name": "Size",
    "values": [
      { "option_value_id": "optv_1kmn0aExample", "value": "12 oz" },
      { "option_value_id": "optv_1kmn0bExample", "value": "16 oz" },
      { "value": "20 oz" }
    ]
  }]
}

Then create the variant with POST /v1/products/{product_id}/variants, which wraps the fields in a variant object and selects the new value by option_value_id.

A few changes are blocked while something depends on them:

  • An active bundle cannot replace its components. Set it to inactive, replace them, then activate it again.
  • A variant in an active bundle cannot be retired, and its selected option values cannot change.
  • An option or value that active variants still select cannot be removed.

Retire what you no longer sell#

DELETE archives products, variants, bundles, modifier groups, and modifier sets. Archived items stay readable by ID, so past orders keep their references. List endpoints return them when you filter for status=archived. For something you only want to pause, set status to inactive instead. That is reversible, and archiving is not.

Flint blocks archiving while the item is still in use:

  • A product, variant, or bundle on an open order, an open checkout session, an active payment link, an active subscription or plan, or an active bundle.
  • A modifier group that a modifier set still uses.
  • A modifier set attached to an active catalog item.
  • The default variant of a simple product. Archive the product instead.

Common errors#

The full list is in the error reference.

Was this helpful?