commercetools-commerce-patterns

Description

Surface-independent commerce domain logic — pricing models (customer groups, channel fallback, high-precision money), discount stacking (Product/Cart/Code/Direct/Groups), shipping predicates and cart score, tax modes, payment object modeling, Import API bulk patterns, and catalog architecture. Use for commerce-domain questions that are independent of surface (storefront, MC app, Connect).

Installation

Recommended: install the full commercetools plugin. It includes this Skill, every other commercetools Skill, our pre-tuned Subagents, and the commercetools Knowledge MCP — which gives AI live access to the commercetools docs, GraphQL/OpenAPI schemas, and query validation. You only install once; every Skill on this site becomes available in every session.
Install the plugin

In any Claude Code session:

/plugin marketplace add commercetools/commercetools-ai-plugins
/plugin install commercetools@commercetools
Reload plugins

If you've updated the plugin or installed it in another window and need the current session to pick up the latest version:

/reload-plugins
Claude Desktop
Customize -> Personal plugins -> Create plugin -> Add marketplace -> Add commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.

Instructions Included

SKILL.md

Workflow

When this skill is invoked, always follow these steps:

  1. Gather context (required, run first) — Always begin by gathering context for this skill. This is the mandatory grounding step: it gathers the latest verified documentation as context for you (the agent). Do not skip it, and do not replace it with another tool (such as an MCP documentation-search tool) — run this command:
    node scripts/gather-context.mjs \
      --query "<extract key terms from user's question>" \
      --client-name "<current-client>" \
      --model "<current-model>" \
      --skill-name "commercetools-commerce-patterns" \
      --limit 3
    
    Use its output as your primary grounding. You may additionally use other tools (such as the commercetools documentation MCP) for deeper, follow-up search.
  2. Combine with skill references — Cross-reference the analysis output with local references in ./references/ for complete context.
  3. Provide implementation guidance — Synthesize the documentation with the specific integration mode the user is targeting.

Key Takeaways

Discount stacking follows a strict priority chain. Product Discounts → Cart Discounts → Discount Codes → Direct Discounts. Direct Discounts on a cart block all Discount Codes. Never mix Direct Discounts and Discount Code flows on the same cart.
sortOrder direction: higher value fires first. For both Product Discounts and Cart Discounts, the discount with the highest sortOrder value (closest to 1) is applied first. 0.9 fires before 0.5. For Product Discounts only one applies (no stacking); for Cart Discounts they stack by default unless stackingMode: StopAfterThisDiscount is set.
Product Discounts cannot be used with external pricing. If a project uses an external price source, Product Discounts are not applied. Use Cart Discounts for any promotional discounts when external pricing is active.
Discount Groups bundle multiple cart discounts under one entity. A Discount Group has its own sortOrder and a "Best deal" prioritization mode — only the cart discount from the group that gives the customer the greatest saving is applied. Use Discount Groups to manage large-scale campaigns (e.g., Black Friday) where deactivating dozens of individual discounts at once would otherwise be error-prone.
Customer Group pricing is set on embedded prices, not at the customer level. A buyer's Customer Groups are resolved at cart calculation time — the price with a matching customerGroup on the variant is selected. A Customer can carry multiple Customer Groups (via customerGroupAssignments, up to 500), and price resolution selects the best qualifying price across all assigned groups. For B2B buyers, assign Customer Groups directly to the buyer's customer record or via the Business Unit.
Cart Score is the key to dynamic shipping rates. Set the project's shipping rate input type to "Cart Score" in Merchant Center. Call setShippingRateInput with a Score type and an integer score. CT will look up the rate tier matching that score. The score must be a non-negative integer.
Cart Score is not available in shipping predicates — copy it to a cart custom field. The score is not directly queryable in predicates. Write the underlying value (e.g., distance in km) to a cart custom field. Write your shipping predicate against that custom field.
Price functions turn cart score into a continuous rate. Configure a priceFunction (e.g., (x / 30)) where x is the cart score. CT evaluates the function and sets the resulting amount as the shipping price, enabling rates that scale smoothly without enumerating every possible value.
Use taxMode: ExternalAmount for most PSP/tax service integrations. This mode lets your tax service calculate the exact tax amount per line item and pass it directly to CT, eliminating rounding discrepancies between CT's engine and external tax services (AvaTax, Vertex).
CT records payment state; the PSP does the actual financial transaction. The Payment object is a ledger. Use addTransaction update actions to track each PSP event (Authorization, Charge, Refund, Chargeback). Only set state: "Success" after confirmed PSP confirmation.
interfaceId on a Payment is immutable once set. This field links CT Payment to the PSP's identifier. Record it correctly the first time — there is no update path.
Cart prices are snapshotted at order creation. If a product price changes between cart creation and order creation, the cart holds the old price. Use the recalculate action on the cart to refresh prices before checkout.
Business Units are the core B2B entity — all B2B operations run through them. All B2B operations use the as-associate API chain (construction and scope in references/as-associate-api.md). Never use project-level cart/order APIs for B2B user-facing operations.
The quote lifecycle has four entities: QuoteRequest → StagedQuote → Quote → Order. A buyer creates a QuoteRequest; a sales rep creates a StagedQuote (working draft with negotiated prices); the Quote is the finalized offer; if accepted, the buyer converts it to an Order. Each step has its own state machine.
POST /orders vs POST /recurring-orders produce fundamentally different results. Calling POST /orders on a cart with recurring line items creates both a regular order (immediate) and a recurring order. Calling POST /recurring-orders creates only the recurring order. Use /orders for "subscribe and ship now" flow; use /recurring-orders for future-dated subscriptions.
Cart discount recurringOrderScope controls whether a discount repeats. Set "NonRecurringOrdersOnly" for one-time welcome promotions. Set "AnyOrder" for standing loyalty discounts that should apply on every recurrence cycle.

Reference Index

Pricing & Promotions

TopicReference
Discount stacking rules, sortOrder, Direct Discount blocking Discount Codesreferences/discount-fundamentals.md
Buy/Get cart discount — triggerPattern, targetPattern, excludeCountreferences/bundle-discounting.md
Cart discount scenario cookbook — 12 named scenarios with full JSONreferences/cart-discount-scenarios.md
Fixed-price combo discount — buy N of X+Y at fixed combined pricereferences/fixed-price-combo.md
"Any N of a set for one fixed total price" (no trigger item), and per-cart firing caps via maxOccurrencereferences/cart-discount-scenarios.md
Customer Group-based pricing — embedded price selection, group assignmentreferences/customer-group-pricing.md
B2B customer group pricing — buyer-specific pricing via Customer Groupsreferences/b2b-customer-group-pricing.md
High Precision Money — fractionDigits, currency configuration, roundingreferences/high-precision-money.md
Discount Groups and Promotion Prioritization — best-deal selectionreferences/discount-groups.md
Store credit & loyalty currency — external ledger patternreferences/store-credit.md
Limited-time pricing — validFrom/validUntil on embedded pricesreferences/limited-time-pricing.md
Discount code usage — code generation, redemption limitsreferences/discount-code-usage.md

Shipping & Fulfillment

TopicReference
Tiered rates, cart score setup, ScoreShippingRateInputreferences/tiered-rates-cart-score.md
Shipping predicates — predicate syntax, custom field workaroundreferences/shipping-predicates.md
Price functions — priceFunction configuration, function syntaxreferences/price-functions.md
Dynamic shipping costs — external calculation, setCustomShippingMethodreferences/dynamic-shipping-costs.md
BOPIS shipping — multiple shipping methods, delivery group setupreferences/bopis-shipping.md

Payments & Tax

TopicReference
Payments — Payment object model, transactions, PSP integration flowreferences/payments.md
Taxes — cart tax modes (Platform/External/ExternalAmount/Disabled)references/taxes.md
Financing & installments — BNPL integration, CT payment modelingreferences/financing-options.md

B2B Order Flows

TopicReference
Custom Associate Roles — role definition, permission keys, assignment to BUreferences/custom-associate-roles.md
Business Unit hierarchy — parent/child BUs, inherited roles, store assignmentreferences/business-unit-hierarchy.md
Approval rules — predicate design, approval flow lifecyclereferences/approval-rules.md
Quote lifecycle — QuoteRequest, StagedQuote, Quote, Order conversionreferences/quote-lifecycle.md
as-associate API pattern — chain construction, scopereferences/as-associate-api.md

Recurring Orders

TopicReference
Recurring orders — RecurrencePolicy, priceSelectionMode, /orders vs /recurring-ordersreferences/recurring-orders.md

Catalog & Import

TopicReference
Product data modeling — hierarchy, all 14 attribute types, Nested vs Custom Objectreferences/product-data-modeling.md
Bundle product modeling — ProductType design, child SKU reference, BFF orchestrationreferences/bundle-modeling.md
Import API — containers, batching, async processing, delta importsreferences/import-api.md
Import API performance — 15M record pattern, container count, batch sizereferences/import-performance.md
Item substitutes — modeling substitute/replacement productsreferences/item-substitutes.md
Inventory modeling — supply channels, inventory entries, backorderreferences/inventory-modeling.md

Priority Tiers

CRITICAL

  • Direct Discounts block Discount Codes. Never add a Direct Discount to a cart if the checkout flow expects users to enter discount codes.
  • sortOrder: higher value fires first — for both Product Discounts and Cart Discounts. 0.9 is applied before 0.5. This is a common misconception that causes incorrect promotion behavior.
  • Product Discounts do not work with external pricing. If the project uses an external price source, switch all promotional discounts to Cart Discounts.
  • triggerPattern SKU predicates are ANDed across entries. A single CountOnLineItemUnits entry can only match one SKU — add separate entries per bundle component.
  • Score must be set before shipping rate lookup. setShippingRateInput must be called before checkout and before shipping rate totals are calculated. Calling it after order creation has no effect.
  • Shipping predicate failures are silent. If a predicate syntax error exists, CT may silently return no methods or all methods. Always test predicate behavior explicitly with GET /shipping-methods?cartId=<id>.
  • All B2B writes MUST use the as-associate API chain. Project-level apiRoot.carts().* does not enforce B2B permissions. This is a security requirement, not just a convention.
  • An Associate must have CreateMyCarts permission to add items to a cart as-associate. Missing permissions result in a 403.
  • POST /orders creates both a regular order and recurring orders from a mixed cart. Design the order confirmation flow to handle multiple order IDs.
  • A RecurrencePolicy cannot be deleted while in use. Remove all line item and cart references before attempting deletion.
  • Never set a Payment transaction to Success before PSP confirmation. Optimistic success marking creates reconciliation failures.
  • interfaceId on a Payment cannot be changed once set. Record the PSP transaction identifier correctly on the first write.
  • Import API has no ordering guarantee. Poll the first operation's status before submitting the second.
  • productDraftImport is destructive. Any field not included in the draft is deleted.
  • Version conflicts (409 ConcurrentModification) require retry with the latest version. Do NOT use the version from the failed request.

HIGH

  • Customer Group pricing requires the customer to be logged in and have a customerGroup set. Anonymous carts get the default price.
  • HighPrecisionMoney must be consistent across all prices in a cart. Mixing centPrecision and highPrecision prices in the same cart/order causes calculation errors.
  • Discount Code maxApplications is global across all customers. Use maxApplicationsPerCustomer for per-customer limits — they are separate fields.
  • Cart Score is not available in shipping predicates. Copy the distance or weight value to a cart custom field before writing predicates against it.
  • Per-warehouse predicates need the warehouse key in a cart custom field. The cart's assigned supply channel is not directly queryable in a shipping predicate.
  • StagedQuotes hold negotiated line item prices. When converting a StagedQuote to a Quote, prices from the StagedQuote are locked in.
  • Approval Rules are evaluated at Order creation, not Cart creation. Design the cart-to-order UX to set buyer expectations about the approval wait.
  • priceSelectionMode must be set intentionally per use case. "Fixed" provides price guarantee; "Dynamic" always applies current catalog pricing.
  • recurringOrderScope on cart discounts defaults to applying on all orders. Explicitly set "NonRecurringOrdersOnly" for introductory discounts.
  • Nested attributes are not searchable and cannot target discount predicates. Use flat attribute types for any attribute that must appear in search filters or predicates.
  • Batch size for Import API is max 20 operations per request. Always chunk.
  • Changing a ProductType attribute constraint after data exists requires a migration. Plan attribute constraints during initial design.

MEDIUM

  • isActive on a Cart Discount is a soft toggle. Use this for scheduled campaigns rather than creating/deleting discounts.
  • Use stackingMode: StopAfterThisDiscount to make a specific discount exclusive. Cart discounts stack by default.
  • Price rounding is configurable via priceRoundingMode (on Cart/Order/Quote and as a Project default). Modes: HalfEven (banker's rounding — the default), HalfUp, HalfDown. Only HalfDown rounds .5 in the customer's favor; align the mode with your ERP/tax system and budget margin accordingly for high-volume campaigns.
  • For unsupported discount scenarios, the pattern is an API Extension on cart/order that calculates and injects a Direct Discount or custom line item.
  • lineItemGrossTotal(categories.key = (...)) >= "X.XX USD" is the pattern for category spend thresholds. The currency string must be included in the value.
  • Price functions are evaluated server-side at rate lookup time. Do not try to replicate the function in your frontend.
  • Quote negotiation supports multiple rounds. Track negotiation rounds via custom fields on QuoteRequest if your business requires round history.
  • Customer Group assignment on the buyer's customer record is the simplest B2B pricing pattern. All their carts inherit the group pricing automatically.
  • Order confirmation UX must handle multiple order IDs. A single cart checkout with recurring items at different frequencies produces N+1 orders.
  • Standalone prices via Import API support validFrom/validUntil for time-limited pricing without product republication.

References

approval-rules.md

Approval Rules


Overview

Approval Rules are predicates defined on a Business Unit that gate Order creation behind a human approval step. When an order being placed by a buyer matches an Approval Rule's predicate, the platform intercepts the order from cart action and places the resulting Order in a Pending state instead of immediately confirming it.

Core Concepts

Approval Rules Are Predicates

An Approval Rule contains a predicate string written in the commercetools query predicate syntax. The predicate is evaluated against the Order at creation time.

Common predicate fields include:

FieldExample use case
totalPrice.centAmountOrders above a spend threshold require approval
lineItems(quantity > X)Large quantity orders trigger approval
lineItems(totalPrice.centAmount > X)High-value individual line items
Custom fieldsBusiness-specific rules (e.g. product category, custom flags)
Example predicate: totalPrice.centAmount > 1000000 (orders over €10,000 at centAmount scale).

Approvers

Each Approval Rule specifies one or more approver tiers — sets of associate roles that must approve the order. Tiers are evaluated sequentially:
  • All required approvers in a tier must act before the next tier is evaluated.
  • An Approval Rule can have multiple tiers to model multi-level approval chains (e.g. line manager → finance director).

Requesters

Rules can also restrict which associate roles the rule applies to (i.e. which buyers' orders are subject to the rule). This allows different rules for different buyer roles within the same BU.


Approval Flow State Machine

When an Order matches an Approval Rule, it enters the approval flow lifecycle:
Order created → Pending

          ┌───────┴───────┐
          │               │
       Approved        Rejected

       Order confirmed / fulfilment continues
StateDescription
PendingOrder is awaiting approval from one or more approver tiers.
ApprovedAll required approver tiers have approved. Order proceeds normally.
RejectedAt least one required approver has rejected. Order is declined.
The state transitions are driven by the Approve and Reject update actions (applied via Update ApprovalFlow by ID), which must be called via the asAssociate API path.

Evaluation at Order Creation

  1. Buyer places an order.
  2. The platform evaluates all active Approval Rules on the buyer's Business Unit (and inherited from parent BUs).
  3. If any rule's predicate matches the order, an Approval Flow is created and the Order is placed in Pending state.
  4. If no rule matches, the Order is confirmed immediately.

Rules from parent BUs are inherited by child BUs unless overridden.


Key API Resources

ResourceEndpoint
Approval RulesGET /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/approval-rules
Approval FlowsGET /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/approval-flows
Approve an OrderUpdate ApprovalFlow by ID (POST /as-associate/{associateId}/in-business-unit/key={buKey}/orders/{orderId}/approval-flows/{flowId}) with action Approve
Reject an OrderUpdate ApprovalFlow by ID (POST /as-associate/{associateId}/in-business-unit/key={buKey}/orders/{orderId}/approval-flows/{flowId}) with action Reject

Implementation Notes

  • Only associates with an approveOrder permission can call approve/reject actions.
  • An order can match multiple Approval Rules simultaneously; the platform creates one Approval Flow that merges all required approver tiers.
  • Approval Rules are managed by associates with the updateApprovalRules permission (typically a BU admin).
  • Approval Rule predicates are validated at creation time — an invalid predicate is rejected with a 400 error.
as-associate-api.md

As-Associate API


Overview

The As-Associate API is a special API path prefix in commercetools that enforces Business Unit–scoped permission checks on behalf of a buyer (associate). Any operation that a buyer performs within the context of a Business Unit — reading orders, creating carts, placing orders, managing quotes, approving flows — must go through this API path. Calling the standard API paths bypasses the BU permission layer and is only appropriate for merchant/admin contexts.

The API Chain Pattern

The TypeScript SDK exposes the As-Associate API through a fluent builder chain:

apiRoot
  .asAssociate()
  .withAssociateIdValue({ associateId: "customer-id-here" })
  .inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey: "bu-key-here" })
  .<resource>()
  .<action>()
  .execute();

Breaking Down the Chain

SegmentPurpose
.asAssociate()Switches the SDK into the as-associate routing context.
.withAssociateIdValue({ associateId })Identifies which customer (associate) is performing the action. The platform validates that this customer is an associate of the target BU.
.inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey })Scopes the operation to a specific Business Unit.
.<resource>()The resource type (e.g. .carts(), .orders(), .quoteRequests()).

Example: Create a Cart as an Associate

const cart = await apiRoot
  .asAssociate()
  .withAssociateIdValue({ associateId: customerId })
  .inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey: "acme-uk" })
  .carts()
  .post({
    body: {
      currency: "GBP",
      country: "GB",
      businessUnit: { key: "acme-uk", typeId: "business-unit" },
      store: { key: "acme-uk-store", typeId: "store" },
    },
  })
  .execute();

Example: Place an Order as an Associate

const order = await apiRoot
  .asAssociate()
  .withAssociateIdValue({ associateId: customerId })
  .inBusinessUnitKeyWithBusinessUnitKeyValue({ businessUnitKey: "acme-uk" })
  .orders()
  .post({
    body: {
      cart: { id: cart.body.id, typeId: "cart" },
      version: cart.body.version,
    },
  })
  .execute();

Why the As-Associate Path Is Required

Platform Permission Enforcement

commercetools enforces B2B permissions at the API layer using the as-associate path. When a request arrives via this path, the platform:
  1. Verifies the associateId is an active associate of the named Business Unit.
  2. Checks whether that associate holds the required associateRole permission for the requested operation (e.g. createMyCarts, createOrders, viewOrders, updateApprovalFlows).
  3. Rejects the request with an AssociateMissingPermissionError (an HTTP 403-class error) if the permission is absent.
Calling /carts, /orders, etc. directly (without asAssociate) uses project-level OAuth scopes only and does not apply associate-role permission logic. This means:
  • A buyer could see or modify another BU's data if project scopes are broad.
  • Approval rule enforcement is bypassed.
  • Audit trail (who did what, in which BU) is lost.
Always use the as-associate path for buyer-facing operations.

Operations Covered

The as-associate path supports the following resource types (non-exhaustive):

ResourceTypical Operations
cartsCreate, read, update, replicate carts scoped to the BU
ordersPlace orders from cart, read orders
orders/quotesCreate an order from an accepted Quote
quote-requestsCreate and manage QuoteRequests
quotesRead, accept, decline Quotes
order-approval-flowsApprove or reject pending orders
business-unitsRead BU details, update associates (where permitted)

REST Equivalent

The SDK chain maps to the following REST path structure:

POST /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/carts
GET  /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/orders
POST /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/orders
POST /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/quote-requests
POST /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/orders/quotes

Common Mistakes

MistakeConsequence
Using /carts instead of asAssociate().*.carts()No associate permission check; any authenticated customer could access any BU's carts if scopes allow.
Omitting businessUnitKey from the cart bodyCart may not be associated with the BU, breaking approval rule evaluation.
Using a merchant/admin token for buyer flowsBypasses all associate-role enforcement; creates audit and security gaps.
Calling approval actions via the standard orders pathNot supported — approval transitions are only available through the as-associate path.

OAuth Scope

The OAuth token used in as-associate requests should carry:

  • manage_my_orders:{projectKey} or manage_orders:{projectKey} (depending on whether you use customer tokens or merchant tokens acting on behalf of a customer).
  • For customer-token flows (recommended for buyer-facing apps): use the customer OAuth flow so the token is bound to the specific customer, and the platform cross-checks associateId against the token's subject.
b2b-customer-group-pricing.md

B2B Customer Group Pricing


Overview

Customer Groups are the primary mechanism for applying differentiated pricing to B2B buyers in commercetools. They work as one of several price selection parameters that the platform evaluates at query time and at line-item addition to select the correct price from a product variant's price list.

Price Selection Parameters

commercetools uses a combination of the following parameters to select a price from the variant's embedded price array:

ParameterNotes
currencyCodeRequired. Drives which price entry is eligible.
countryOptional. Narrows selection to country-specific prices.
customerGroupOptional. Narrows selection to group-specific prices.
channelOptional. Narrows selection to channel-specific prices (e.g. distribution channel).
priceDate (validFrom / validTo)Optional. Filters prices that are valid at the given point in time.
These parameters are also known as price selection criteria.

How Price Selection Works

If currency and additional price selection parameters are passed in a Product Projection Search request, the platform applies price selection logic and returns the matching price for each product/variant. See the official docs: Price Selection.

2. Cart Line Items

When addLineItem is called on a cart, the platform uses the cart's own price selection parameters (currencyCode, country, customerGroup, channel) to resolve the price for that line item. For price selection to work correctly:
  • The relevant price selection parameters must be set on the cart before or at the time line items are added.
  • The Customer Group is typically derived from the customer's profile and propagated to the cart automatically, but it can also be set explicitly.
Reference: Carts API

3. Fallback / Default Price

It is strongly recommended to include a default price (one without any customer group, channel, or country constraint) within the variant's pricing model. This serves as a fallback when none of the more specific price selection criteria match, preventing scenarios where no price is resolved.

Customer Group Constraints and Limitations

Deletion Constraint

A Customer Group cannot be deleted while it is still referenced by product pricing entries. Attempting to do so returns an HTTP 400 error with the message:

"Can not delete a source while it is referenced from at least one 'product'."

Migration workflow when replacing a Customer Group:
  1. Create the new Customer Group.
  2. Update all product prices (and any discounts referencing the old group) to reference the new Customer Group.
  3. Delete the old Customer Group once no pricing entries reference it.

Multiple Groups Per Customer

A Customer can hold multiple Customer Groups via customerGroupAssignments (up to 500). The legacy single customerGroup field still exists but is no longer the only option. Price selection resolves the best qualifying price across all assigned groups. If a buyer's pricing tier changes (e.g. they move from "Silver" to "Gold"), update the customer's group assignments accordingly.

B2B Recommendations

  • Segment pricing tiers using Customer Groups (e.g. tier-bronze, tier-silver, tier-gold, distributor, reseller).
  • Always define a base/default price to avoid null prices for customers not assigned to any group.
  • Use Channel + Customer Group together when pricing also varies by distribution channel (e.g. online vs. in-store B2B).
  • Automate group assignment as part of the customer onboarding or account management workflow so carts always carry the correct Customer Group from the start.
bopis-shipping.md

BOPIS Pattern Using Multiple Shipping Methods

Overview

Consider a scenario where you want to allow customers to pick up certain products in-store while shipping others to their home or a designated delivery address. While the process for managing this use case is documented at https://docs.commercetools.com/tutorials/multiple-shipping-addresses-methods#using-multiple-shipping-methods, this article provides additional context and insights.

High Level Flow

  1. Set the shippingMode on the Cart to Multiple
  2. Add item shipping addresses (store pickup address + customer delivery address)
  3. Query shipping methods and evaluate predicates to determine eligibles
  4. Add selected shipping methods to the cart
  5. Set lineItemShippingDetails on each line item
  6. Create the order

Step 1: Set the shippingMode on the Cart

Composable Commerce supports two shippingMode values for carts: Single (the default) and Multiple. You must set the shippingMode in the initial cartDraft request, as it cannot be modified later.
Since customers may not initially know whether they'll need multiple fulfillment methods, the recommended approach is to create the cart with shippingMode set to Single.

If a customer later decides to use multiple fulfillment methods in the cart's life cycle, you can:

  • Create a new cart with shippingMode set to Multiple.
  • Copy over the line items and discount codes from the original cart to the new one using the addLineItem & addDiscountCode Update Cart actions.
    • This will be your cart going forward.
    • The redundant cart with shippingMode Single may be abandoned and would get deleted based on the lastModifiedAt datetime value, or you may choose to delete this yourself using the Delete Cart by ID operation.
Example CartDraft request:
{
    "key": "example-cart-key",
    "currency": "USD",
    "country": "US",
    "ShippingMode": "Multiple",
    "lineItems": [
      // ...
    ]
  }

Step 2: Add the itemShippingAddress

Add the pick up store's address and the customer's delivery location to the cart using the addItemShippingAddress update cart action as shown below:
{
    "version": {{cart-version}},
    "actions": [
        {
            "action" : "addItemShippingAddress",
            "address" : {
              "key" : "pickup-store-address",
              "firstName" : "My Store",
              "lastName" : "Store 140",
              "streetName" : "Commerce Blvd",
              "streetNumber" : "1701",
              "postalCode" : "90210",
              "city" : "Los Angeles",
              "state" : "NC",
              "country" : "US"
            }
        },
        {
            "action" : "addItemShippingAddress",
            "address" : {
              "key" : "customer-shipping-address",
              // ...
            }
          }
    ]
}
This action creates an itemShippingAddresses array on the cart. Use logical key values for addresses, as these keys will be referenced in subsequent steps.

Step 3: Query shippingMethods to determine eligibles

Once item shipping addresses are added, determine the eligible shipping methods for the cart.

Important: The shipping-methods/matching-cart API does not support carts with shippingMode set to Multiple.
Instead, use the Query shipping-methods endpoint or a GraphQL query to fetch all shipping method IDs and their predicates. Example:
query {
  shippingMethods {
    results {
      id
      predicate
    }
  }
}

{
  "data": {
    "shippingMethods": {
      "results": [
        {
          "id": "17fa25a7-6d70-4c98-8610-1cd04ad87450",
          "predicate": "<predicate-string>"
        },
        {
          "id": "b47f7f2d-a0e3-48d3-88df-4da3b99e34b9",
          "predicate": "<predicate-string>"
        }
      ]
    }
  }
}
Evaluate the predicates against the state values in the itemShippingAddresses array to determine applicable shipping methods. Present these options to the customer for selection.

Step 4: Add the selected shippingMethod to the cart

Add the selected shipping methods using the addShippingMethod update action. When using the Multiple shippingMode, a shippingAddress must be provided for each shipping method.

Example payload:

{
    "version": {{cart-version}},
    "actions": [
        {
            "action" : "addShippingMethod",
            "shippingKey" : "in-store-pickup",
            "shippingMethod" : {
              "id" : "{{shipping-method-id}}",
              "typeId" : "shipping-method"
            },
            "shippingAddress" : {
              "key" : "pickup-store-address",
              "streetName" : "Commerce Blvd",
              "streetNumber" : "1701",
              "postalCode" : "90210",
              "city" : "Los Angeles",
              "state" : "NC",
              "country" : "US"
            }
          },
          {
            "action" : "addShippingMethod",
            "shippingKey" : "customer-delivery",
            "shippingMethod" : {
              "id" : "{{shipping-method-id}}",
              "typeId" : "shipping-method"
            },
            "shippingAddress" : {
              "key" : "customer-delivery-address",
              // ...
            }
          }
    ]
}

Step 5: Setting the lineItemShippingDetails on each line item

Use the setLineItemShippingDetails action to associate each line item with its corresponding shipping method and address. Example:
{
    "version": {{cart-version}},
    "actions": [
        {
            "action" : "setLineItemShippingDetails",
            "lineItemId" : "{{lineItemId}}",
            "shippingDetails" : {
              "targets" : [ {
                "addressKey" : "my-store-address",
                "shippingMethodKey": "in-store-pickup",
                "quantity" : 1
              } ]
            }
          }
    ]
}

Conclusion

Once all line items are associated with shipping details, you can proceed to create an order from the cart.

Key Rules and Gotchas

  • shippingMode must be set at cart creation time — it cannot be changed later.
  • The shipping-methods/matching-cart API does not work for Multiple shippingMode carts. Use the generic Query shipping-methods endpoint and evaluate predicates client-side.
  • Each addShippingMethod action in Multiple mode requires its own shippingAddress.
  • Use meaningful key values for both itemShippingAddresses and shipping methods (shippingKey) since these keys link addresses to line items.
  • If starting with a Single mode cart and needing to switch: create a new Multiple mode cart and copy over lineItems and discountCodes; abandon the old cart.
bundle-discounting.md

Bundle Discounting Pattern Using 'Buy Get Cart Discount'


This document outlines how to configure bundle discounts using the Buy/Get Pattern Cart Discount functionality.

The discount will apply only when all targeted SKUs in a bundle are present in a customer's cart. These discounts can be either absolute ($ off) or relative (% off). Since there are no predefined product types representing bundles, the setup leverages the flexibility of the Buy/Get Cart Discount feature to achieve this goal.

Key Requirements

  • Bundle Criteria: Discounts trigger only when all SKUs in the specified bundle are in the cart.
  • Discount Type: Support for both absolute ($ off) and relative (% off) discounts.
  • SKU-Based Trigger: The discount applies based on the presence of specific SKUs.

Solution Overview

The Buy/Get Cart Discount feature allows for setting conditions and outcomes for discounts, making it ideal for implementing bundle-based promotions. Here's how to set it up:

Define the Trigger

Using the Merchant Center, specify the Variant ID (SKU) required for the discount. For example, if SKUs LPC-09, ADPC-09 & LPC-011 form the bundle:

"triggerPattern": [
    {
        "type": "CountOnLineItemUnits",
        "predicate": "sku = \"LPC-09\"",
        "minCount": 1,
        "maxCount": 1
    },
    {
        "type": "CountOnLineItemUnits",
        "predicate": "sku = \"ADPC-09\"",
        "minCount": 1,
        "maxCount": 1
    },
    {
        "type": "CountOnLineItemUnits",
        "predicate": "sku = \"LPC-011\"",
        "minCount": 1,
        "maxCount": 1
    }
]

Set the Discount Target

Use the targetPattern to apply the discount to the bundle as a whole. Specify the eligible SKUs in the Merchant Center:
"targetPattern": [
    {
        "type": "CountOnLineItemUnits",
        "predicate": "sku = \"LPC-09\"",
        "minCount": 1,
        "maxCount": 1,
        "excludeCount": 0
    },
    {
        "type": "CountOnLineItemUnits",
        "predicate": "sku = \"ADPC-09\"",
        "minCount": 1,
        "maxCount": 1,
        "excludeCount": 0
    },
    {
        "type": "CountOnLineItemUnits",
        "predicate": "sku = \"LPC-011\"",
        "minCount": 1,
        "maxCount": 1,
        "excludeCount": 0
    }
]
While setting the targetPattern, you have the option of configuring the excludeCount setting.
The excludeCount feature ensures that items used to trigger a discount (e.g., "Buy 3") are excluded from receiving the discount themselves. Once a discount iteration is applied to a cart, the excluded and discounted items from that iteration are locked in and won't be considered for subsequent iterations of the same discount. If there aren't enough items left to meet the trigger condition, the discount stops.

Set Discount Value and Distribution

Discount of this type may be absolute ($ off) or relative (% off), and you can decide if the discount should be:

  • Distributed evenly across all eligible items
  • Applied individually to each eligible item
  • Distributed proportionately across all eligible items
Discount Distribution is configurable for "Amount Off" and "Fixed Price" discount types. Proportional distribution is also available for relative ("Percentage Off") discounts in Buy & Get promotions.

Example discount value configuration (absolute, even distribution):

"value": {
    "type": "absolute",
    "money": [
        {
            "type": "centPrecision",
            "currencyCode": "EUR",
            "centAmount": 1000,
            "fractionDigits": 2
        },
        {
            "type": "centPrecision",
            "currencyCode": "GBP",
            "centAmount": 1000,
            "fractionDigits": 2
        },
        {
            "type": "centPrecision",
            "currencyCode": "USD",
            "centAmount": 1000,
            "fractionDigits": 2
        }
    ],
    "applicationMode": "EvenDistribution"

Distribution Modes for Relative (%) Discounts in Buy & Get

Relative (percentage-off) discounts in Buy & Get promotions support a distribution mode that spreads the discount amount proportionally across all involved line items (both trigger and target), not just the target item. Prorating the discount across both trigger and target line items means a return of the trigger item generates a correct prorated refund derivable directly from the original order, which simplifies return handling and financial reconciliation.
Example: "50% off the cheapest Category A item when you spend $100+ in Category A"

With proportional distribution, the 50% discount is spread across all qualifying items rather than applied entirely to the cheapest one.

Available distribution modes for relative Buy & Get discounts:
Mode (MC label)API value (applicationMode)Behavior
distributed proportionally across all involved itemsProportionateDistributionProrates discount across all trigger + target items
distributed evenly across all involved itemsEvenDistributionSplits discount equally across trigger + target items
only applied to the discounted itemsIndividualApplicationApplies the discount only to the targeted (discounted) items

Benefits of This Approach

  • Flexibility: Supports a wide range of discount configurations (absolute or relative, distribution rules).
  • Precision: Ensures discounts apply only when all bundle SKUs are present.
  • Efficiency: Eliminates the need for manual bundle definitions by leveraging the Buy/Get Cart Discount functionality.
bundle-modeling.md

Bundle Modeling


Part 1: Pattern for Static Bundle Pricing

Consider a scenario where you want to offer a static collection of products to your customers at a fixed price. This bundle allows your business to provide value by incentivizing purchases of complementary items.
This document explores the recommended method of selling bundles at a fixed price using the commercetools platform, examining the high-level implementation, benefits, and limitations.

Solution Overview: Product Modeling

The online commercetools documentation recommends defining a ProductType to represent bundles, where the bundle ProductType includes Attributes that reference the child Products contained within the bundle.

This approach works well for many use cases, such as offering a special bundle price for a "shirt and trousers bundle," where customers can choose specific variants (e.g., size and color) at the time of purchase. In such cases, the bundle references the product as a whole, allowing flexibility in variant selection.

However, some use cases may require directly associating ProductVariants (SKUs) with the bundle. For example, an iPhone and iPhone case bundle may involve predefined variants (e.g., a specific iPhone model with a matching case), where the pricing or compatibility depends on the exact variants included. This requires managing a list of SKUs for precise control rather than referencing the product in its entirety.
If your use case involves single-variant Products or ProductVariants with minimal differences in pricing or configuration, follow the standard commercetools process.
For scenarios requiring direct associations between ProductVariants and a bundle ProductType, the process involves creating a ProductType per bundle and defining Attributes that reference the included ProductVariants.

Solution Overview: LineItem Flow

When a customer adds a bundle to their cart, your backend-for-frontend (BFF) service should handle the following:

  • Add the bundle product to the cart: Include the bundle as the parent item.
  • Add associated child items to the cart: Represent the bundle components as lineItems.
  • Link child items to the parent item: Establish relationships between child and parent lineItems using custom fields.
  • Ensure a fixed bundle price: Set the price of the child lineItems to $0 using externalPrice.

Additional constraints may include:

  • Prevent modifications to child items: Restrict direct changes to child lineItems in the cart.
  • Propagate changes from parent to children: Ensure updates to the bundle, such as quantity adjustments, are applied to the child lineItems.

Defining the Bundle ProductType

To represent a bundle, create a ProductType (e.g., my-store-bundle) and define a Set<Text> Attribute called bundleContents. This approach avoids creating a separate ProductType for each specific bundle. Instead, you can use bundleContents attribute to differentiate between bundles, allowing for more flexibility and scale in bundle management.
{
  "name" : "my-store-bundle",
  "description" : "my-store-bundle",
  "attributes" : [ {
    "type" : {
      "name" : "set",
       "elementType": {
                "name": "text"
            }
    },
    "isSearchable" : true,
    "name" : "bundleContents",
    "label" : {
      "en" : "bundleContents"
    },
    "isRequired" : true,
     "attributeConstraint": "Unique"
  }]
}
Alternatively, you may choose to define the bundleContents Attribute as a string to hold comma-separated SKUs instead of a set.

Creating the Bundle Product

Once the ProductType is defined, create the bundle Product. Define its SKU, populate the bundleContents Attribute with the SKUs of the included products, and set a fixed price for the bundle:
{
  // generic product info e.g. name, description, productTypeReference, slug and etc.
  // ...
  "masterVariant": {
    "name": {
    "en": "ivory-dinner-bundle"
  },
    "sku": "IDIN-01",
    "attributes": [
      {
        "name": "bundleContents",
        "value": ["ISP-01", "SPOO-094", "SGB-01"]
      }
    ],
    "prices": [
                    {
                        "id": "<id>",
                        "value": {
                            "type": "centPrecision",
                            "currencyCode": "USD",
                            "centAmount": 1999,
                            "fractionDigits": 2
                        }
                    },
                    // ...
                ]
  }
}
Alternatively, if the ProductType was defined to refer to Products via references, here is what the bundle Product would look like:
{
  // generic product info e.g. name, description, productTypeReference, slug and etc.
  // ...
  "masterVariant": {
    "name": {
    "en": "ivory-dinner-bundle"
  },
    "sku": "IDIN-01",
    "attributes": [
                    {
                        "name": "child-items",
                        "value": [
                            {
                                "typeId": "product",
                                "id": "<referenced-product-id>"
                            },
                            {
                                "typeId": "product",
                                "id": "<referenced-product-id>"
                            }
                        ]
                    }
                ],
    "prices": [
                    {
                        "id": "<id>",
                        "value": {
                            "type": "centPrecision",
                            "currencyCode": "USD",
                            "centAmount": 1999,
                            "fractionDigits": 2
                        }
                    }
                ]
  }
}
While these examples use EmbeddedPrices, you may alternatively use StandalonePrices or ExternalPrices based on your requirements. For bundles with multiple quantities of the same child item, consider a convention such as attributes[].value: ["ISP-01||2, SPOO-094|1"], or a similar structure.

Extending the lineItem Object for Bundle-Child Relationships

To establish relationships between bundle and child lineItems, extend the lineItem object with a custom field named parentLineItemId:
{
  "key": "parent-lineitem-id",
  "name": {
    "en": "parentLineItemId"
  },
  "description": {
    "en": "Custom type for child lineitems that are part of a bundle"
  },
  "resourceTypeIds": [
    "line-item"
  ],
  "fieldDefinitions": [
    {
      "name": "parentLineItemId",
      "label": {
        "en": "LineItem ID of the parent, bundle item"
      },
      "required": false,
      "type": {
        "name": "String"
      }
    }
  ]
}

Orchestration Logic for Adding Bundles to Carts

With the foundational setup complete, implement orchestration logic to automatically add child items when a bundle is added to the cart.

Key Steps:
  1. Detect if the item is a bundle product: Check if the ProductVariant being added references a bundle-specific ProductType.
  2. Validate and extract bundle contents: Perform validations (e.g., inventory checks) and retrieve bundleContents to get the child ProductVariants.
  3. Add the bundle item to the cart: Use the AddLineItem action to add the bundle as a parent item.
  4. Add child items: Add each child ProductVariant to the cart with matching quantities using the AddLineItem action.
  5. Set child item properties: Assign an externalPrice of $0 and populate parentLineItemId to establish linkage to the parent (bundle lineItem).
  6. Handle updates and removals: Ensure bundle quantity changes or removals propagate to child lineItems.

UI Considerations

  • Hide child lineItems in the cart UI to reduce clutter — unless your use case requires the customer to select specific product variants to be part of a bundle.
  • Prevent direct modifications or removals to child lineItems to preserve bundle integrity.

Other Considerations

When deciding to include child lineItems in the Cart or Order through BFF logic, specific business requirements should guide the approach. Key factors to consider include:
  • Inventory Tracking: Does inventory need to be tracked at the child lineItem level?
  • Reporting Needs: Are separate reports required for the child items within the bundle?
Additionally, if child lineItems are added as part of a bundle, ensure cart discounts are built to exclude these items to avoid double-discounting. This can be achieved by using predicates tied to the custom parentLineItemId field.

Concluding Thoughts

This approach treats bundles as distinct items, offering significant benefits for categorization and search functionality on your storefront. For example:

  • Customers can search for bundles just like any other product.
  • You can classify bundles into different categories, making them easier to discover.
  • Each bundle can have its own unique images, descriptions, and search slugs, providing flexibility in how bundles are presented to customers.

Additional considerations may be needed such as changes to BFF, orchestration logic, etc.


Part 2: Bundles Hands-On (Implementation Example)

Bundle ProductType that References ProductVariants

Creating the Bundle ProductType

Consider a scenario where you want to create a Product Bundle that comprises specific Product SKUs (i.e., ProductVariant), and are OK with a "soft reference". In this scenario, start with defining the ProductType as shown below:
{
            "name": "bundle-type-1",
            "description": "bundle-type-1",
            "classifier": "Complex",
            "attributes": [
                {
                    "name": "bundle-content-skus",
                    "label": {
                        "en-US": "bundle-content-skus"
                        // other languages
                    },
                    "isRequired": false,
                    "type": {
                        "name": "set",
                        "elementType": {
                            "name": "text"
                        }
                    },
                    "attributeConstraint": "None",
                    "isSearchable": false,
                    "inputHint": "SingleLine",
                    "displayGroup": "Other"
                }
            ],
            "key": "bundle-type-1"
        }

Creating the Bundle Product & ProductVariant

Once you have the above ProductType defined, you can create the "Slate & Stone" bundle Product & ProductVariant (SAS-01) that comprises of a light grey ceramic plate (LCP-02), a harvest plate (HP-01) & rustic bowl (RB-01):
{
    "id": "<id>",
    "version": 2,
    "productType": {
        "typeId": "product-type",
        "id": "<referenced-product-id>"
    },
    "masterData": {
        "current": {
            // generic product info e.g. name, description, productTypeReference, slug and etc.
            // ...
            "masterVariant": {
                // variant info
                // ...
                "attributes": [
                    {
                        "name": "bundle-content-skus",
                        "value": [
                            "LCP-02,HP-01,RB-01"
                        ]
                    }
                ],
                "assets": []
            },
            "variants": []
        }
    },
    "key": "slate-and-stone-bundle",
    "taxCategory": {
        "typeId": "tax-category",
        "id": "<id>"
    },
    "priceMode": "Standalone",
    "lastVariantId": 1
}

Setting the Bundle ProductVariant Inventory

The inventory for the bundle SKU (SAS-01) is managed separately from the inventories of its individual items. However, there is a method to reduce the inventory of the bundle's components when a bundle is sold (discussed later).

Set the inventory of the bundle SKU SAS-01:

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/inventory' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "sku" : "SAS-01",
  "quantityOnStock" : 10,
  "availableQuantity" : 10
}'

{"id":"<id>","version":1,"sku":"SAS-01","quantityOnStock":10,"availableQuantity":10,"reservations":[]}

Setting the Bundle ProductVariant Price

The price for the bundle SKU (SAS-01) is managed independently from the prices of its individual products. Generally, the price of the bundle item is typically lower than the sum of the prices of the individual participating items.

You can choose to use either embedded or standalone pricing. In this example, standalone pricing is used:

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/standalone-prices' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "sku" : "SAS-01",
  "value" : {
    "currencyCode" : "USD",
    "centAmount" : 1599
  }
}'

{
    "id": "<id>",
    "version": 1,
    "sku": "SAS-01",
    "value": {
        "type": "centPrecision",
        "currencyCode": "USD",
        "centAmount": 1599,
        "fractionDigits": 2
    },
    "active": true
}

Creating Required CustomField

To manage inventory deductions and auto add/remove of the bundle-content items, define a CustomField on the lineItem called parentLineItemId:
curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/types' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "key": "parent-lineitem-id",
  "name": {
    "en": "parentLineItemId"
  },
  "description": {
    "en": "Custom type for child lineitems that are part of a bundle"
  },
  "resourceTypeIds": [
    "line-item"
  ],
  "fieldDefinitions": [
    {
      "name": "parentLineItemId",
      "label": {
        "en": "LineItem ID of the parent, bundle item"
      },
      "required": false,
      "type": {
        "name": "String"
      }
    }
  ]
}'

Adding a Bundle Item to a Cart

When a bundle item such as SAS-01 is added to the Cart with the inventory mode set to ReserveOnOrder (None and TrackOnly aren't covered here, since they are simpler to implement than ReserveOnOrder), the following logic needs to be implemented by the BFF:
Step 1: Add the SAS-01 lineItem to the Cart:
curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "key": "example-order-key",
    "currency": "USD",
    "country": "US",
    "shippingMode": "Single",
    "shippingAddress": {...},
    "inventoryMode":"ReserveOnOrder",
    "lineItems": [
      {
        "sku": "SAS-01",
        "quantity": 1}
    ]
  }
  '
The response will include the cart with the bundle lineItem ID (e.g., b6e9bbac-36ed-4ddb-8ae8-989638067e8d) for SAS-01, which is needed to link the child items.
Step 2: Since the bundle product lineItem has the bundle-content-skus attribute defined, iterate through this list and add the "child" lineItems to the cart:
  • Set LineItemPriceMode to ExternalPrice with a value of $0 for all child line items.
  • Set the CustomField parentLineItemId on the child line items to match the bundle product's lineItem ID.
curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "version": 5,
    "actions": [
        {
            "action" : "addLineItem",
            "sku" : "LCP-02",
            "quantity" : 1,
            "externalPrice":{
                "currencyCode":"USD",
                "centAmount":0
            },
            "custom":{
                "type":{
                    "key":"parent-lineitem-id",
                    "type":"line-item"
                },
                "fields":{
                    "parentLineItemId":"<actual-parent-lineitem-id>"
                }
            }
        },
        {
            "action" : "addLineItem",
            "sku" : "HP-01",
            "quantity" : 1,
            "externalPrice":{
                "currencyCode":"USD",
                "centAmount":0
            },
            "custom":{
              // same as the above child lineitem 
            }
        },
        {
            "action" : "addLineItem",
            "sku" : "RB-01",
            "quantity" : 1,
            "externalPrice":{
                "currencyCode":"USD",
                "centAmount":0
            },
            "custom":{
                // same as the above child lineitem 
            }
        }
    ]
}'

Performing the above actions ensures that inventory is reduced for the child items that comprise the bundle, along with reducing the inventory of the bundle item.

Special Considerations

Product Discounts

  • Given that the child items are added to the cart with an external price of $0, product discounts should not impact these items.
  • Product discounts on the bundle items will apply as usual.

Cart Discounts

  • It is recommended to build cart discount predicates to exclude lineItems where the CustomField parentLineItemId is defined.

Multi-quantity Purchases

  • If a customer purchases multiple quantities of bundle items, the corresponding number of child items will need to be added to the Cart by the BFF layer.

Lineitem Edits

  • Custom UI logic may be necessary to either not show the child line items on the website's UI, or to prevent end-users from editing the child lineItem details.

Removing the Bundle Item from the Cart

  • Custom BFF logic needs to be added to auto-remove the child lineItems when the bundle item is removed from the Cart.

Handling "OutOfStock" Errors

  • If the platform returns an OutOfStock error for the bundle item or any of the child lineItems, the BFF layer needs to handle this as one single entity and prevent the sale of the bundle product.

Part 3: Reference Option Decision Guide

When modeling the bundleInfo attribute that links a bundle product to its components, four options exist:
OptionAttribute TypeProsCons
Product hard referenceSet<product reference>Clickable in Merchant Center; good for manually-maintained bundlesImport order dependency — cannot reference a product that doesn't exist yet; references the product as a whole, not a specific variant
Custom-object hard referenceSet<custom-object reference>CoCo can hold variant-level detail and arbitrary JSON; flexibleImport order complexity; CoCo must be expanded or fetched separately on PDP/PLP — less efficient than inline variant data
Products/variants soft referenceSet<text> (SKU or product key)Can reference a specific variant; import order irrelevant (no hard link)Not clickable in Merchant Center; if a variant is removed the SKU becomes a dangling reference
Products/variants soft reference as JSONtext (escaped JSON string)All soft-reference benefits + structured custom metadata per component (quantity, slot, notes)JSON string must be parsed; no schema enforcement
Recommendation: text with an escaped JSON value (the fourth option) is the most flexible for automated systems. Use Product hard reference only when bundles are maintained manually in Merchant Center and the import-order dependency is manageable.

Import-order Risk with Hard References

If bundle creation is automated (Import API), a bundle product cannot be created if any of its referenced component products do not yet exist in CT. Mitigation: use the Import API with a dependency-aware ordering, or accept a 48-hour eventual-consistency window (Import API creates missing dependencies within that window). For large catalog imports, this dependency tracking adds significant orchestration complexity — prefer soft references.

Bidirectional bundleId + parentId Pattern

An alternative to the parentLineItemId-only approach is a bidirectional link using two custom fields on the line-item type:
  • bundleId — set on the bundle (parent) line item with a unique identifier for that bundle instance in the cart
  • parentId — set on each child line item, pointing to the parent's bundleId
This is more robust than a unidirectional parentLineItemId because:
  1. Cart replication works correctly. When a cart is replicated (re-order, quote request creation), new line item IDs are generated. A parentLineItemId pointing to the old line item ID breaks. bundleId/parentId use a stable business identifier — the link survives replication.
  2. Dynamic/configurable bundles. For build-to-order bundles where the customer assembles a configuration on the PDP, the bundleId can encode the configuration (e.g., a hash of selected options). This lets you determine whether an add-to-cart should create a new bundle line item or increment the quantity of an existing one.
  3. Bidirectional traversal. Finding all children of a bundle: lineItems.filter(li => li.custom.parentId === bundleId). Finding the parent of a child: lineItems.find(li => li.custom.bundleId === childParentId).

Dynamic and Configurable Bundles

Static bundles have fixed components. Two more patterns exist:

Choice bundles: The bundle has a fixed price, but customers pick components from defined groups (e.g., "any jacket + any trousers from these categories at €199"). Implementation: use two categories (or product selections) to define the groups; the storefront guides the customer through selection. The cart contains the bundle product (with its own price) plus the selected component line items at €0.
Hybrid bundles: Some components are fixed and some are customer-selectable (e.g., a specific phone model + a charger + a case chosen from N options). Model the fixed components as standard bundleInfo entries and the selectable slot as a category or attribute reference. The cart structure is the same: bundle at full price, all components at €0.
Price-per-component bundles with discount: If the bundle price is the sum of component prices minus a discount (rather than a fixed price), add all components at their normal prices with a custom field marking them as bundle participants, then apply a cart discount predicate filtered to that custom field. Note: this approach is not self-discoverable — there is no single "bundle price" to display on a PLP. Use this only when the discount mechanics are the primary value, not the bundle as a purchasable product.
business-unit-hierarchy.md

Business Unit Hierarchy


Overview

The Business Unit (BU) hierarchy is the structural backbone of commercetools B2B. It models the real-world organisational structure of a buying company — from the top-level corporate entity down through divisions, subsidiaries, and teams — and controls how associates, stores, and permissions are scoped.


Conceptual Hierarchy

Organization (top-level Company BU)
└── Division / Region (Division BU)
    └── Team / Department (Division BU)
        └── Individual Buyer Account (Division BU)
commercetools represents all levels using the Business Unit resource. There are two unitType values:
unitTypeDescription
CompanyThe root of a BU tree. Has no parent.
DivisionAny non-root node. Must reference a parent BU.
A BU tree can be up to 5 levels deep. Each BU has exactly one parent (or none, for Company BUs).

Key Concepts

Stores and Business Units

  • Stores are assigned to a Business Unit via the stores array on the BU resource.
  • A Store on a BU scopes all carts and orders created within that BU to that Store's catalogue, price, and inventory scope.
  • A BU can reference multiple Stores; a Store can be linked to multiple BUs.
  • Associates can only act on carts/orders belonging to the BUs they are explicitly assigned to, even if the same Store is linked to another BU.

Associate Roles and Inheritance

  • Associates are linked to a BU via the associates array, which specifies which associateRoles they hold within that BU.
  • Role inheritance flows downward: roles granted on a parent BU are inherited by all descendant BUs, unless explicitly overridden.
  • This allows centralised role management at high levels of the hierarchy while still supporting fine-grained overrides at lower levels.

Store Inheritance

  • When a BU has storeMode: FromParent, it inherits the store assignments of its nearest ancestor that explicitly defines stores.
  • When storeMode: Explicit, the BU uses only the stores listed on its own stores array.

Common B2B Hierarchy Patterns

Flat (single-level)

Company BU
├── Buyer Account A
├── Buyer Account B
└── Buyer Account C

Suitable for simple B2B portals where all buyer accounts are peers.

Regional / Divisional

Company BU  (global)
├── Division BU  (EMEA)
│   ├── Division BU  (Germany)
│   └── Division BU  (France)
└── Division BU  (APAC)
    └── Division BU  (Australia)

Useful when pricing, catalogues, or associates are region-specific.

Cost-Centre / Department

Company BU  (Enterprise Customer)
└── Division BU  (Procurement Department)
    ├── Division BU  (IT Budget)
    └── Division BU  (Facilities Budget)

Enables spend controls and approval rules at the department level.


API Resource Summary

  • Create BU: POST /business-units
  • Get BU: GET /business-units/{id} or GET /business-units/key={key}
  • Get BU tree: Use GET /business-units with predicate topLevelUnit(id="{companyId}") to retrieve all descendants.
  • As-Associate context: All buyer-facing mutations must go through the asAssociate API path to enforce permission checks (see as-associate-api.md).

Customer Groups on Business Units (Multi-Group Pricing)

Both Customers and Business Units support up to 500 Customer Group assignments via the customerGroupAssignments field, which is the recommended best practice for new projects. The single customerGroup field is still supported (not deprecated).
Critical architecture constraint: For B2B-specific (Customer Group-scoped) prices to apply to a cart, BOTH of the following must be non-null:
  • cart.businessUnit — the cart must be associated with a Business Unit
  • businessUnit.customerGroupAssignments — the BU must have at least one Customer Group assigned

Price selection evaluates all assigned Customer Groups and resolves the cheapest matching price.

Migration note: Cart Discount predicates that target Customer Groups should use the full path customer.customerGroupAssignments.customerGroup.key contains "..." with the contains operator.

Bulk Import of Business Units

Business Unit data can be bulk-imported via the Import API using the BusinessUnitImportRequest import type. Supports both Company and Division types. Up to 20 BUs per import request. Useful for large B2B onboarding migrations.

Project-Level Defaults for Self-Service B2B Onboarding

Two project-level defaults affect self-service B2B onboarding design:

  • Default Business Unit Status: Configurable in MC. When the default is Active, BUs created via self-registration are active immediately and require no explicit activation step. If you configure the default to Inactive, design your onboarding flow to handle the activation step.
  • Default Associate Role: A default role assigned automatically to the creating Associate when a new BU is created via the me endpoint. Eliminates the need for a post-creation role assignment step in self-service flows.

Limitations

ConstraintValue
Maximum hierarchy depth5 levels
Parents per BU1
Stores per BUUnlimited (practical limits apply)
Associates per BU2000
cart-discount-scenarios.md

Cart Discount Scenarios — Concrete Configurations

A scenario cookbook for common cart discount requirements. Each scenario includes the business intent, the key structural choices, and the JSON that implements it.


Item Discount Distribution Modes

When using an Item cart discount (targets line items, not total price), the amount can be distributed across eligible line items in three ways:
ModeDescriptionWhen to use
IndividuallyDiscount value applied to each eligible line item independentlyPercentage off (relative) discounts — each item gets the same %
Distributed EvenlyTotal discount split equally across all eligible itemsFixed amount split uniformly
Distributed ProportionatelyTotal discount split across items in proportion to their priceFixed or absolute amount spread fairly; avoids over-discounting cheap items
Configurable for: applicationMode is configurable for both absolute (amount off / fixed price) and relative (percentage off) values across Item, Buy and Get, and Discount Bundles effects.
Note — applicationMode availability: The applicationMode field is available on both absolute/fixed cart discount values (CartDiscountValueAbsoluteDraft) and relative (percentage-based) cart discount values (CartDiscountValueRelativeDraft). The three modes apply identically regardless of value type:
  • ProportionateDistribution (default): Distributes the discount proportionally across matched line items by their price
  • EvenDistribution: Distributes the discount evenly across matched line items regardless of price
  • IndividualApplication: Applies the full discount amount to each matched line item independently

This matters for Buy and Get and Discount Bundles scenarios with relative values where you want to control how the percentage discount is spread — for example, distributing a 50% discount proportionately across trigger + target items rather than applying the full percentage to each item individually.


Proportional Distribution in Buy & Get

Relative Buy & Get discounts can distribute the discount amount proportionately across both trigger and target line items — not just the target. This means:
  • The discount is still calculated based on the target item's price
  • But the discounted amount is prorated across all involved line items (trigger + target)
  • Return scenarios are simpler: each line item carries its proportional discount, derivable without manual recalculation
Example: "50% off the cheapest Category A item when you spend $100+ in Category A" — with proportional distribution, the discount spreads across all qualifying items rather than applying 50% entirely to one.

Configure in MC: Buy and Get discount → "distributed proportionately across all involved items" (not "only applied to the discounted items").


Buy and Get — Apply On Parameter

When the targetPattern is broader than a single specific variant (e.g., a category that applies to multiple line items), the Apply On parameter determines which items are selected for the discount:
  • Cheapest items — sort eligible items low-to-high, apply discount starting from the least expensive
  • Most expensive items — sort eligible items high-to-low, apply discount starting from the most expensive
If the targetPattern specifies a single variant SKU, this setting has no effect (only one item qualifies).
In JSON, this maps to selectionMode:
  • "Cheapest" = Cheapest items
  • "MostExpensive" = Most expensive items

Buy and Get — Multiple Applications Parameter

Controls how many times a Buy and Get discount can fire per cart:

SettingJSONBehavior
DisabledmaxOccurrence: 1 (or omit with single match)Fires at most once per cart
Enabled (Unlimited)omit maxOccurrence or set nullFires as many times as the pattern matches
Enabled (Specify N)maxOccurrence: N (N > 1)Fires up to N times per cart
Use Unlimited for BOGO-style promotions that should apply to every qualifying pair. Use Specify N for limited promotions (e.g., "up to 3 times per order"). Use Disabled for one-time promotions.

Core Structural Concepts

Buy and Get vs Discount Bundles

Two target types share the pattern structure but serve different purposes:
FeatureBuy and GetDiscount Bundles
triggerPatternRequired (non-empty)Empty []
targetPatternRequiredRequired
Use case"Buy X, get Y discounted""Any N of these items at discount/fixed price"
MC effect type"Buy and Get""Discount bundles"
Discount Bundles applies directly when the cart contains the targeted items — no separate trigger is needed. Buy and Get requires the trigger condition to be met first, then discounts the target items.

selectionMode

When more qualifying items are in the cart than maxCount allows, selectionMode determines which items get the discount:
  • "Cheapest" — discounts the least expensive qualifying items (protects margin on high-value items)
  • "MostExpensive" — discounts the most expensive qualifying items (greater perceived value for the customer)

maxOccurrence

Controls how many times the discount pattern can fire per cart. Set to 1 if the discount should apply at most once (e.g., "buy 3 get $150 off — once per cart"). Omit or set null for unlimited application (e.g., BOGO that applies to every pair).

categories.key vs categoriesWithAncestors.key

  • categories.key = "beds" — matches only items directly assigned to a category with key "beds"
  • categoriesWithAncestors.key contains "beds" — matches items in "beds" AND any subcategory of "beds"
Use categoriesWithAncestors when products are organized in a hierarchy and the discount should apply to the whole tree.

value types

  • "relative" with permyriad — percentage discount (10000 = 100%, 5000 = 50%, 2500 = 25%)
  • "absolute" with money array — fixed amount off (in centAmount)
  • "fixed" with money array — set the total price of the targeted items to this amount (e.g., "3 items for $15 total")

Buy X Get Y Scenarios

Scenario 1: Buy 2+ of product A, get 1 of product B free

Business intent: Buy two or more large ceramic plates and get a classic beer mug for free.
{
  "value": { "type": "relative", "permyriad": 10000 },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "product.key = \"large-ceramic-plate\"",
        "minCount": 2
      }
    ],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "product.key = \"classic-beer-mug\"",
        "minCount": 1,
        "maxCount": 1
      }
    ],
    "maxOccurrence": 1,
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • Trigger has no maxCount — 2 or more plates qualify.
  • Target maxCount: 1 ensures only 1 beer mug is made free.
  • maxOccurrence: 1 — the free mug is given once per cart regardless of plate quantity.
  • permyriad: 10000 = 100% off the target item.

Scenario 2: Spend $100+ on item from category A, get $25 off an item from category B

Business intent: Spend at least $100 on a furniture item and get $25 off any home decor item.
{
  "value": {
    "type": "absolute",
    "money": [{ "currencyCode": "USD", "centAmount": 2500, "fractionDigits": 2 }],
    "applicationMode": "ProportionateDistribution"
  },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"furniture\" and price.centAmount > 10000",
        "minCount": 1,
        "maxCount": 1
      }
    ],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"home-decor\"",
        "minCount": 1,
        "maxCount": 1
      }
    ],
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • price.centAmount > 10000 inside the trigger predicate filters on per-item price (centAmount 10000 = $100.00), not cart total. This is a CountOnLineItemUnits predicate — it can combine category and price conditions on the line item.
  • Use lineItemGrossTotal in cartPredicate instead if you need a category spend threshold (see Scenario 5).

Scenario 3: Buy N of category A, get M of category B for a fixed price

Business intent: Buy 3 beds, get any 2 rugs for $75.
{
  "value": {
    "type": "fixed",
    "money": [{ "currencyCode": "USD", "centAmount": 7500, "fractionDigits": 2 }],
    "applicationMode": "ProportionateDistribution"
  },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"beds\"",
        "minCount": 3
      }
    ],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"rugs\"",
        "minCount": 2,
        "maxCount": 2
      }
    ],
    "maxOccurrence": 1,
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • value.type: "fixed" sets the total price of the 2 target items to $75, distributed proportionately across both.
  • maxCount: 2 in target — exactly 2 rugs receive the fixed price.

Scenario 4: Cart contains item from category A AND category B → $100 off order total

Business intent: Get $100 off if the cart has at least one bedroom furniture item AND one living room furniture item.
{
  "value": {
    "type": "absolute",
    "money": [{ "currencyCode": "USD", "centAmount": 10000, "fractionDigits": 2 }],
    "applicationMode": "ProportionateDistribution"
  },
  "cartPredicate": "lineItemExists(categories.id contains \"<bedroom-category-id>\") = true and lineItemExists(categories.id contains \"<living-room-category-id>\") = true",
  "target": { "type": "totalPrice" },
  "references": [
    { "typeId": "category", "id": "<bedroom-category-id>" },
    { "typeId": "category", "id": "<living-room-category-id>" }
  ]
}

Key notes:

  • lineItemExists AND compound — the only way to express "cart must contain at least one item from category A AND at least one from category B" in a cart predicate. Each lineItemExists clause is a separate condition.
  • Uses category IDs, not keys. IDs are resolved via the references array on the cart discount (shown in MC as "Category" reference picker).
  • target.type: "totalPrice" — discount applies to the overall cart total, not specific line items.
  • This is a Total Price discount effect in MC, not a Buy and Get or Discount Bundles effect.

Scenario 5: Spend $500+ on category A items → percentage off a specific product

Business intent: Spend at least $500 on furniture products and get 25% off a classic beer mug.
{
  "value": { "type": "relative", "permyriad": 2500 },
  "cartPredicate": "lineItemGrossTotal(categories.key = (\"furniture\")) >= \"500.00 USD\"",
  "target": {
    "type": "lineItems",
    "predicate": "product.key = \"classic-beer-mug\""
  }
}

Key notes:

  • lineItemGrossTotal(categories.key = (...)) >= "X.XX USD" — the only predicate function for "spend threshold on a specific category." Takes the sum of gross prices for all matching line items. Currency must be included in the string value.
  • target.type: "lineItems" with a predicate — a simpler target form that applies to all matching line items (not capped by maxCount).
  • This is an Item discount effect in MC, combined with a cart predicate.

Discount Bundles Scenarios

Scenario 6: N items for the price of M (percentage-based)

Business intent: Buy 5 large ceramic plates for the price of 3 (pay for 3, get 2 free → 40% off).
Math: 2 free items out of 5 = 40% discount. permyriad = 4000.
{
  "value": { "type": "relative", "permyriad": 4000 },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "product.key = \"large-ceramic-plate\"",
        "minCount": 5,
        "maxCount": 5
      }
    ],
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • Empty triggerPattern: [] — this is a Discount Bundles effect, not Buy and Get.
  • minCount: 5, maxCount: 5 — the discount applies to exactly a set of 5. A second set of 5 would also qualify (unlimited occurrence).
  • N for price of M formula: permyriad = ((N - M) / N) * 10000. For 5 for 3: (2/5) * 10000 = 4000.

Scenario 7: BOGO 50% (buy 1 get 1 at half price)

Business intent: Buy one furniture item, get a second furniture item at 50% off.
Math: 50% off one item across 2 items = 25% off each. permyriad = 2500.
{
  "value": { "type": "relative", "permyriad": 2500 },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key = (\"furniture\")",
        "minCount": 2,
        "maxCount": 2
      }
    ],
    "selectionMode": "MostExpensive"
  }
}

Key notes:

  • permyriad: 2500 = 25% off each of 2 items = financially equivalent to 50% off 1 item.
  • selectionMode: "MostExpensive" — discounts the 2 most expensive furniture items (maximizes customer's perceived value).
  • No maxOccurrence → applies to every pair of qualifying items in the cart. Add maxOccurrence: 1 to cap at one BOGO per cart.
  • BOGO 50% permyriad formula: permyriad = (discount_percent / 2) * 100. For 50% off one: (50/2) * 100 = 2500.

Scenario 8: Fixed price for up to N items

Business intent: Buy up to 3 classic beer mugs for $15 total.
{
  "value": {
    "type": "fixed",
    "money": [{ "currencyCode": "USD", "centAmount": 1500, "fractionDigits": 2 }],
    "applicationMode": "ProportionateDistribution"
  },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "product.key = \"classic-beer-mug\"",
        "minCount": 1,
        "maxCount": 3
      }
    ],
    "maxOccurrence": 1,
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • minCount: 1, maxCount: 3 — the $15 fixed price applies to 1, 2, or 3 mugs (whichever are in the cart, up to 3).
  • applicationMode: "ProportionateDistribution" splits the $15 across the selected items proportionally.
  • maxOccurrence: 1 — the $15 deal fires only once per cart.

Scenario 9: Category quantity threshold → fixed amount off

Business intent: Purchase any 3 beds (category key: beds) and get $150 off those items.
{
  "value": {
    "type": "absolute",
    "money": [{ "currencyCode": "USD", "centAmount": 15000, "fractionDigits": 2 }],
    "applicationMode": "ProportionateDistribution"
  },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"beds\"",
        "minCount": 3
      }
    ],
    "maxOccurrence": 1,
    "selectionMode": "MostExpensive"
  }
}

Key notes:

  • No maxCount on the target — the $150 is distributed across all 3+ qualifying bed items.
  • selectionMode: "MostExpensive" — the 3 most expensive beds are selected for discount attribution.
  • maxOccurrence: 1 — $150 off is applied once even if the cart has 6+ beds.

Scenario 10: First item in a category is free (including subcategories)

Business intent: The first product from any 'beds' category (including subcategories) is free.
{
  "value": { "type": "relative", "permyriad": 10000 },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categoriesWithAncestors.key contains \"beds\"",
        "minCount": 1,
        "maxCount": 1
      }
    ],
    "maxOccurrence": 1,
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • categoriesWithAncestors.key contains "beds" instead of categories.key contains "beds" — this is the only way to include products assigned to subcategories of "beds" (e.g., "king-beds", "bunk-beds"). Use categoriesWithAncestors whenever the category hierarchy matters.
  • maxCount: 1 + maxOccurrence: 1 = exactly one free item per cart, choosing the cheapest.

Scenario 11b: Buy 2 of the same item at a fixed price, limit 5 applications per order

Business intent: Any 2 candle holders for $5.00 each (fixed bundle price), applicable up to 5 times per cart.
This uses Discount Bundles (no trigger) with maxOccurrence: 5 and Fixed price type.

MC configuration:

  • Effect type: Discount bundles
  • Count: is equal to 2, Item Criteria: category key includes "candle-holders"
  • Apply On: Cheapest items
  • Multiple applications: Enabled, specify 5 times
  • Discount type: Fixed price
  • Discount value: EUR 5.00

API JSON:

{
  "value": {
    "type": "fixed",
    "money": [{ "currencyCode": "EUR", "centAmount": 500, "fractionDigits": 2 }],
    "applicationMode": "ProportionateDistribution"
  },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"candle-holders\"",
        "minCount": 2,
        "maxCount": 2
      }
    ],
    "maxOccurrence": 5,
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • maxOccurrence: 5 caps the number of qualifying pairs — a cart with 12 candle holders gets the deal on 5 pairs (10 items), 2 items at full price.
  • Fixed price type distributes the total proportionately across both items in the pair.

Scenario 11c: Buy 3 of an item, get the next 2 at half-price

Business intent: Each time the cart has at least 3 candle holders, the next 2 (up to) get 50% off.

This uses Buy and Get: trigger on 3+ items, target up to 2 items at 50% off.

MC configuration:

  • Trigger: "Each time the cart contains" — count is at least 3, category key includes "candle-holders"
  • Target: "Apply Discount on" — count is up to 2, category key includes "candle-holders"
  • Apply On: Cheapest items
  • Multiple applications: Disabled (fires once per pattern match)
  • Discount type: Percentage off, 50

API JSON:

{
  "value": { "type": "relative", "permyriad": 5000 },
  "cartPredicate": "1 = 1",
  "target": {
    "type": "pattern",
    "triggerPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"candle-holders\"",
        "minCount": 3
      }
    ],
    "targetPattern": [
      {
        "type": "CountOnLineItemUnits",
        "predicate": "categories.key contains \"candle-holders\"",
        "minCount": 1,
        "maxCount": 2
      }
    ],
    "selectionMode": "Cheapest"
  }
}

Key notes:

  • The trigger and target reference the same category — this is valid. CT handles the deduplication.
  • maxCount: 2 on target — at most 2 items receive the half-price discount per trigger occurrence.
  • No maxOccurrence — if the cart has 9 candle holders, the pattern fires 3 times (3 trigger → 2 discounted, 3 trigger → 2 discounted, 3 trigger → 2 discounted).

Gift Line Item and Shipping Scenarios

Scenario 11: Spend $100, get a free gift item

Business intent: When the cart net total reaches $100, add a specific gift product to the cart at zero cost.
{
  "value": {
    "type": "giftLineItem",
    "product": { "typeId": "product", "id": "<gift-product-id>" },
    "variantId": 1
  },
  "cartPredicate": "cartNetTotal >= \"100.00 USD\""
}

Key notes:

  • Use value.type: "giftLineItem" — the dedicated gift value type. When the cartPredicate evaluates true, commercetools automatically adds the referenced product variant to the cart as a gift; you do not pre-add it and do not set a target. The gift product variant must have a price that can be selected for the cart (add supplyChannel/distributionChannel to the value if channel-specific price selection is needed).
  • In the cart response, the gifted line item has "lineItemMode": "GiftLineItem", quantity: 1, and totalPrice.centAmount: 0. This mode is what distinguishes gift items from regular purchased items.
  • A 100% relative discount on a normal line item does not produce a GiftLineItem — the line item keeps lineItemMode: Standard and is merely discounted to zero. Use giftLineItem for a true auto-added gift.
  • Only one item can be added per gift line item discount; for "customer's choice of gift from N items," let the customer add their chosen product and price it to zero with a separate relative/absolute discount instead.
  • The cart predicate uses cartNetTotal (excludes shipping and tax). Use totalPrice to include shipping in the threshold calculation.

Scenario 12: Free shipping when cart total ≥ $35

Business intent: Remove the shipping fee entirely when the cart net total is $35 or more.
{
  "value": { "type": "relative", "permyriad": 10000 },
  "cartPredicate": "cartNetTotal >= \"35.00 USD\"",
  "target": { "type": "shipping" }
}

Key notes:

  • target.type: "shipping" — applies the discount to the cart's shipping cost. This is distinct from the lineItems and totalPrice targets.
  • permyriad: 10000 = 100% off shipping. For partial shipping discounts (e.g., "$5 off shipping"), use "type": "absolute" with a money array instead.
  • In the cart response, shippingInfo.price.centAmount will show the original shipping rate, while shippingInfo.discountedPrice.centAmount will show 0.
  • Shipping discounts can also be configured three ways: via shipping methods in Project Settings, as a cart discount targeting shipping, or through discount codes. The cart discount approach shown here is the most flexible for conditional free-shipping campaigns.
  • The cartNetTotal predicate computes the sum of all line item prices before shipping and tax. This ensures the threshold is based on product spend only, not the shipping cost itself.

Promotion Prioritization — Product Discounts vs Cart Discounts

When both a Product Discount and Cart Discount apply to the same item, the default behavior is stacking (both apply). This can erode margins when a cart discount applies on top of an already-discounted product price.
Promotion Prioritization is a project-level setting in Merchant Center → Settings → Project Settings → Miscellaneous:
ModeBehavior
Best DealEngine compares product discount and cart discount; applies only the one giving the greatest saving to the customer. The cart response includes a chosenDiscountType field showing which type won.
Apply 1 product discount + multiple cart discountsDefault stack behavior — one product discount applies, then cart discounts stack on top
Use Best Deal when product discounts and cart discounts can target the same items and you want to prevent double-dipping. This setting cannot be overridden on a per-cart basis.

Discount Codes — Key Limits and Store-Specific Behavior

Usage limits

FieldDescription
maxApplicationsMaximum total uses of this code across all customers
maxApplicationsPerCustomerMaximum uses per individual customer
Validity periods (validFrom / validUntil) are optional. No validity period = code is always valid until manually deactivated.

Capacity limits

  • One discount code can reference up to 10 cart discounts
  • A cart can have up to 10 discount codes applied simultaneously

Store-specific discount codes

Discount codes can be restricted to specific stores by associating them with store-aware cart discounts:
  • No additional implementation required — link the discount code to a cart discount that has stores set
  • The code automatically inherits the store context from the cart discount
  • The stores field on a cart discount is an array — one discount can apply across multiple stores
  • A cart discount can be associated with up to 500 stores; a discount code reaches stores indirectly through the cart discount(s) it references
  • Feature activates implicitly when you associate a code with a store-specific cart discount — no project-level toggle
Use case: Multi-region or multi-store merchants where a promotional code should only work in the intended regional store. Prevents code conflicts across stores and simplifies campaign management.
custom-associate-roles.md

Custom Associate Roles


Overview

commercetools B2B supports fully customisable associate roles. Roles are the primary mechanism for grouping permissions and assigning them to associates within a Business Unit. The platform ships with a set of predefined roles, but projects may create, edit, and delete roles to match their own permission model.


Frequently Asked Questions

How do you create custom roles in commercetools?

Custom associate roles are created via the Associate Roles API. See the public documentation for full details: Associate Roles — commercetools documentation

Can roles be grouped above the role level (e.g. "role groups" containing 30+ users)?

No. commercetools does not provide a native concept of "role groups" that sit above individual roles within a Business Unit. Roles themselves group permissions. If a higher-level grouping is needed (e.g. to manage 30+ users uniformly), that logic must be built in a custom service layer that assigns users to BUs with the appropriate roles.

Can B2B roles be customised from the out-of-the-box set?

Yes. Projects may:

  • Create new roles with any combination of permissions.
  • Edit existing roles to add or remove permissions.
  • Delete roles that are no longer needed.

Can a user hold multiple roles?

Yes. An associate can be assigned multiple roles within a Business Unit. There is currently no hard platform limit on the number of roles per associate, unless the project reaches edge-case scale (thousands of roles for a single user).


Business Unit Hierarchy and Role Inheritance

Can Business Units have a parent–child hierarchy?

Yes. A Business Unit can be assigned a single parent Business Unit, creating a tree structure. A BU can only have one parent.

Can properties and roles/permissions be inherited from parent Business Units?

Yes. Roles and permissions configured on a parent Business Unit are inherited by its child Business Units, allowing centralised permission management.

What are the hierarchy depth limitations?

  • Maximum 5 levels deep.
  • Each BU can have only one parent.

Stores and Business Units

When a Store is linked to a Business Unit, carts and orders created within that context are scoped to that Store. The practical implications are:

  • Permissions granted to an associate on a BU control visibility of carts and orders for the Stores listed under that BU.
  • An associate cannot act on behalf of a BU they are not assigned to, even if the same Store is linked to that BU.
  • This provides a clear data segregation boundary: Store-scoped data is only accessible through the BU–Associate relationship.
customer-group-pricing.md

Customer Groups Based Pricing


Please provide recommendations / best practices for setting up customer groups and special pricing

Customer groups are typically used in conjunction with Currency Code, Country and Channel to define a rich set of price rules. These are price selection parameters.

Price selection parameters include:
  • CurrencyCode
  • Country
  • Customer Group (single group; on Product Projection search / price selection, the parameter is priceCustomerGroup)
  • Customer Group Assignments (multi-group resolution; on Product Projection search / price selection, the parameter is priceCustomerGroupAssignments)
  • Channel
  • PriceDate (represented as validFrom and validTo)

  1. If price currency and additional price selection parameters are included in product projection search, the platform will use price selection logic to return the matching price to the customer.

  2. When adding lineItems to carts, the platform will use the currency and additional price selection parameters to select the same price. In order for price selection to work when calling addLineItem, the price selection parameters (i.e. currencyCode, country, customerGroup...) must be present on the cart. See: https://docs.commercetools.com/api/projects/carts
  3. When defining pricing that includes price selection criteria, it is recommended to include a default price within the variant pricing model that can be used as a fall back.


Customer Group associated with a price

Customer groups assigned to pricing within the product catalog cannot be deleted without first removing the pricing entries utilizing the customer group. If a deletion request for a customer group contained in product pricing is made, the platform will return a 400 exception containing the message:

"Can not delete a source while it is referenced from at least one 'product'."

In this situation, it would be required to first create the new customer group, modify all discounts referencing the existing customer group and then delete the existing customer group from the platform.

discount-code-usage.md

Discount Code Usage


How to retrieve discount code usage / application counts.

Option 1: Via GraphQL Query

{
  discountCodes {
    results {
      id
      applicationCount
    }
  }
}

Option 2: Via CSV Export from Merchant Center

When you're on the discount code list UI, select all the codes in the list and click on export. Then on the export UI, go to the next screen after you choose the data you need to export. On this UI, choose the option "Select fields from an imported CSV", where you'd provide your CSV based on the format provided in the commercetools documentation.
discount-fundamentals.md

Discount Fundamentals


Part 1: Discounts Topics

Use of "Price - Customer group ID" on product discounts

It is a little bit confusing: If "Price - Customer group ID" is used in a product discount predicate; the product discount is applied to prices defined for a specific customer group. If a customer that belongs to the group but the price is a price that applies to all customer groups the discount will not apply.

This can work only if they have specific prices for the customer group.

Example: Price - Customer group ID is My_Customer_Group — if the price used in the line item is not specifically defined for this customer group (for example a default price for all channels, customer groups, countries, etc) then the discount will not apply even if the customer belongs to that customer group.


Q: I have a use case where a specific promotion is available if the signed-in user is the artist that designed a specified product. On the current site, if you are an artist and it's your product, it would show on the PDP that you are getting a discount.

From the API, it seems like you can't apply this type of promotion at the product level, but you can do it at the cart level (using the user assigned to the cart). This means that it's not automatically applicable when showing the PDP.

Is this correct?

Answer: Yes, this use case can be achieved using cart discounts and the assigned user on cart. However, for PDP they can store the artist id or their unique identifier (i.e. user id) to match and show eligible discounts using front-end customization. If the design is stored as product/sku in ct then there should be a product attribute defining this user-id.

Q: I have a use case where different promotions may be applied based on the direct artist discount, groups you may belong to or general promotions. The system should choose the best combination of promotions. I think we could do this using promotion sorting and early exit from stacked promotions, but it seems a little tricky at the user level and would probably be easy to make a mistake. Is there some way to do this?
Answer: Stacked promotions can be used along with combinations of user specific discounts. The carts will need to have a custom field at cart or line level to indicate the artist's promotion eligibility which can be applied in cart discount predicates.

Q: If commercetools applies a set of promotions, how do we see which ones were applied? From the API, it seemed like only single promotions were displayed. What if they stack?
Answer: The cart should show all discounts at line level including matched discounts. It also shows the applied discount codes.

Q: Can customers use Cart-discounts or Product-discounts endpoint to search for applicable discounts potentially for PDP pages or showing applicable discounts for a signed-in customer? See below scenarios:

i) Search for all applicable discounts for a signed-in customer using customer email or custom fields on customer resources?

ii) Can cart discounts be queried by custom fields values? For example, search all cart-discounts where customer-type is "Employee" (here the customer-type is a custom field on a cart discount)

iii) Search for all discount codes for a particular customer-group or search discounts codes using custom fields?

These scenarios work well only when a cart is configured and the cart has these fields out of the box or through custom fields. Customer is looking for finding applicable promotions even before customer builds a cart and browsing as a signed-in customer.

Answer: This can be implemented by creating a "ghost" cart in the background for the customer on the PDP which was then able to calculate any discounts applicable to the product, they then dispose of the cart. The advantage of this is you could also expand it to take into account what is already in their cart or even their customer group etc.

Q: How to support a use case where customer can see all applicable discounts when they're signed-in (not on the PDP page but may be on their my account page)?
Answer: There is no native API support for this. Theoretically there is a query endpoint for discounts which you could use to search for all applicable discounts for the product, but it wouldn't calculate the value for a customer. For large catalogs, these endpoints may return significant result sets — paginate appropriately.

Q: BOGO Type of discount - Cart Discount Use Case & Solution

Buy 2 for $99 Polos. Expected outcome:

  • 2 items in cart - Buy 2 for $99
  • 3 items in cart – 2 for $99, 3rd at full price
  • 4 items in cart – 2 for $99 applies, so total is $198
  • 5 items in cart – 2 for $99 applies to 4 items, 5th item is full price
Challenge: The current setup removes the promo from the cart when you have 3 or 5 items in the cart. This is due to the qualifiers targeting it when there are only 2 items in cart and 4 items in cart. I've looked at multi-buy as an alternative, but it doesn't work because you can only target with a % off and because products in the promotion are at different price points you can't get the $99 fixed price. There are screenshots attached of the cart discount conditions.

Is there another way to get to the outcome they are after using the commercetools promotions engine?

Solution: You can implement this use case as below:
  1. Add the even qty items (2, 4, 6, and so on) as one line item on cart with a line item custom field defined and single qty item (qty=1) as a separate line item on the cart. The cart will add separate line items if custom fields/its values are different.

  2. Create a fixed price cart discount with Cart Promotion Rule & Cart Qualifier as below:

  3. Fixed price for each line item = 49.5 if the double qty sells for 99 (half the original price)

  4. Promotion Rule -> product.id or sku = "xyz" and custom.<line_item_custom_field_name> = "<custom value>"
  5. Cart Qualifier Rule -> lineItemExists(quantity = 2) = true or lineItemExists(quantity = 4) = true or lineItemExists(quantity = 6) = true

Alternatively, the bundle price option is another viable approach, though the above works without changing the product data model.


Q: Gift with Purchase Use Case: Solution to implement gift with purchase where customer can choose one from a given selection of items as a gift?

Details: Customer buys item X which makes them eligible for a gift with purchase, however, they get to choose one item from the list of 10 items instead of the one predefined product as a gift. Our current promotion rules allow a predefined product as a gift but does not offer a selection of choices to customer. Multibuy promotions option at line level also doesn't solve this.

Solution: This can be solved by not using gift promotions rather a 100% discount and using a hidden category/10 skus as a rule to give 100% on. That category/skus can be maintained in code in frontend or rather as information in original product - item X.

Q: Exclude Discounts: has anyone come up with a way to exclude discounts from applying if the item in the cart is already participating in a cart discount? I can achieve this when an item is participating in a product discount but not for cart discount.
Solution: To solve this they can apply a little custom logic to make the VIP cart discount exclusive.
  1. Use a custom field on cart discount of 2-for-1 discount to mark as "Not to combine with VIP" or any appropriate flag/indicator if this discount cannot be combined with VIP or any other discount.

  2. When adding a 2-for-1 cart discount to the cart, update a custom field on the cart to mention that this discount cannot be combined with VIP discount.

  3. Configure the VIP cart discount predicate to check for cart discount custom attribute where its value is NOT "VIP Discount" or the expected value.

    In this scenario the VIP discount won't get applied based on the predicates.

  4. Additionally, you can use custom fields on VIP discount as well to consider for the vice-versa scenario where 2-for-1 discount cannot be applied if VIP discount exists on a cart.


Q: Standalone promotions - Can we use it?

Yes, you may use the Discounts endpoint API (Cart Discounts, Product Discounts, Discount Codes) to query for discounts in commercetools. To be able to use automatic cart discounts efficiently, a dummy cart or a real cart is more appropriate to see if a cart is eligible for such discounts where no discount code is required.


Q: Coupons and promotions - external validations for discount codes in commercetools - can we do it?

API extensions cannot be created on discounts resources therefore, this validation flow will have to be a custom logic that you may have to implement by applying custom logic. A better way to manage this would be to have a feed come from your coupons system either via nightly job or a cron job (every 15/30/60 mins?) to commercetools and then update the validity on discount codes in commercetools so that the discounts in commercetools are valid and can be used on carts & checkout.


Q: A customer has a coupon code that gives the customer free shipping. They are referencing the cart for the list of shipping methods. Do they need to add the shipping method onto the cart before they get to checkout — so that it can be visually applied? It's more of a user experience issue than the coupon not working. They are trying to see how to solve so that the customer sees it as free shipping prior to getting to the payment part of checkout.
  • Assuming that the coupon code is set with cart discount where free shipping is the target for that discount to be applied on a cart.

  • When the coupon code for free shipping is added to cart, it will show the discount code as "Matches Cart" (as long as the cart qualifies with the cart discount condition) — the storefront can use this info to show the eligibility of free shipping to the customer.

  • Now on checkout, when customer adds shipping address and chooses a shipping method or when the storefront adds a shipping method based on the shipping address, the shipping charges will automatically be discounted as the cart discount is active. Meaning if the shipping charge was $20, it will show the shipping fee of $20 as price but it will also show discounted shipping price as zero under shipping info.

Long story short, they don't need to add shipping address to know the eligibility of a free shipping discount code.


Q: For cart discounts and discount codes. Are there any restrictions for updating the validity dates for cart discounts and discount codes?
Example: If a coupon code has expired. Are we able to go into the Merchant Center and update the expiry?

You can edit/extend the validity dates. There is no restriction on modifying those dates.


Q: Direct Discount Limitations

If direct discounts are present:

  • discount codes: NOT supported (blocked)
  • cart discounts: NOT supported (blocked)
  • product discounts: Supported (allowed)

Part 2: Discount Solutions to Un-supported Scenarios

Q: Following discount scenario not supported today:

The scenario is as follows: If a customer buys more than one units of a specific product they want the first unit to be for example $50 and the rest to have a 20% discount.

Tiered prices does not support it, because the price applies to all units and the customer wants to charge the first one a different (higher) price. In addition if there are other conditions for this discount to apply that can be added to a cart discount predicate but not for prices.

Multibuy discounts also do not provide a solution, for example buy 1 get 1 with 20% discount works great for 2 units but not for 3 (only even numbers).

A: The way to resolve this is programmatically, with an API extension called each time a line is added/removed. The extension will check the quantity and will add a direct discount as necessary or a custom line item with a negative price.

Q: Following discount requirement not supported today.

The use case is as follows: If a customer buys 1 product A and one product B, they want to provide a specific discount (absolute or percent) to one of the products (A or B). They are trying to achieve a discounted price for the pair without implementing bundles.

Multibuy does not provide a solution because if the predicate requires for example SKU to be either product A OR product B then for example 2 units of product A will get the discount.


Key Platform Limits (Discounts)

The following limits are subject to change — confirm current values in the public limits docs (https://docs.commercetools.com/api/limits.md):
LimitValueNote
Cart discounts per project (active + inactive)platform limit appliessee limits docs
Cart discounts requiring codesplatform limit appliessee limits docs
Cart discounts per discount code10Documented
Discount codes per cart10Documented
Active product discounts per project500Documented
Cart discounts per Discount Group100Documented
Discount Groups per project100Documented

Discount Type Quick Reference

Product Discounts:
  • Can only be used with platform pricing (not ExternalAmount/External)
  • Applied at product variant level, before items are added to the cart
  • Do NOT stack — only the highest-ranked (lowest sortOrder value) applies per variant
  • Max 500 active at once
Cart Discounts:
  • Work with platform or external pricing
  • Applied when items are added to the cart
  • Stack by default unless stackingMode: "StopAfterThisDiscount" is set
  • Rounding: configurable via priceRoundingMode (default HalfEven)
Shipping Promotions — Three Ways:
  1. Via shipping method's freeAbove threshold in Project Settings
  2. As a Cart Discount with target.type: "shipping"
  3. Via discount codes linked to a shipping cart discount
A: The way to resolve this is programmatically, with an API extension called each time a line is added/removed. The extension will check how many pairs of product A and product B are in the cart and will add a direct discount as necessary or a custom line item with a negative price.
discount-groups.md

Discount Groups and Discount Combination Mode


Discount Groups

Discount Groups let you bundle multiple cart discounts under a single logical entity. This is the primary tool for managing large-scale campaigns like Black Friday or Cyber Week.

Why use them

Without Discount Groups, disabling a campaign means toggling off each individual discount rule one by one. A Discount Group can be deactivated in a single action, instantly stopping all discounts in that group.

Limits

  • Up to 100 Cart Discounts per Discount Group
  • Up to 100 Discount Groups per project

How selection works within a group

When a cart qualifies for multiple discounts within the same Discount Group, the platform applies only the one that results in the greatest saving for the customer ("Best deal" mode). The group's sortOrder controls prioritization relative to other groups and standalone discounts.

Discount application sequence when Discount Groups are active:

  1. Product discounts are pre-computed and applied to the product price.
  2. Cart discounts are computed against the original (non-product-discounted) price. If cart discounts belong to a group, only the best one from that group is selected.
  3. The product-discounted price is compared against the cart-discounted price, and the lower of the two is applied to the cart.

Creating a group

Each Discount Group has:

  • A key (unique identifier)
  • A rank / sortOrder (a globally-unique decimal across all cart discounts and Discount Groups; higher fires first)
  • A Discount prioritization mode — currently only "Best deal" is available (applies the discount with the lowest resulting cart price)
To assign a cart discount to a group, set the optional Discount group field on the cart discount. The cart discount inherits the group's priority. This can be changed at any time.

discountCombinationMode (Project-Level Setting)

The discountCombinationMode project setting controls how Product Discounts and Cart Discounts interact when both are active on the same cart. It is a project-level setting configured in Merchant Center under Settings → Project Settings → Miscellaneous. It cannot be changed on a per-cart basis.

Two modes

ModeBehavior
BestDealThe engine compares the product-discounted price vs. the cart-discounted price and applies whichever results in the lower cart price for the customer. Only one type applies (not both simultaneously).
Stacking (default; Merchant Center label "Apply 1 product discount + multiple cart discounts")One product discount applies to the product variant price, then cart discounts apply on top in sortOrder sequence. Stacking can erode margins when a cart discount applies on top of an already-discounted product price.

BestDeal application sequence

  1. Product discounts are pre-computed and applied to the product price.
  2. Cart discounts are computed against the original (non-product-discounted) price (if discounts belong to a Discount Group, only the best one per group is selected).
  3. The product-discounted price is compared against the cart-discounted price. The lower of the two is applied.
Warning: With Stacking mode (default), a cart discount applies on top of an already-discounted product price. This can result in double-discounting and margin erosion during sales periods.

discountTypeCombination response field

When discountCombinationMode is active, the cart response includes a discountTypeCombination object with two distinct subfields:
  • type — the combination mode that was applied (Stacking or BestDeal)
  • chosenDiscountType — the winning discount type (ProductDiscount or CartDiscount)

Use this field for discount analytics and debugging unexpected discount outcomes.


Platform Limits Reference (Cart Discounts)

LimitValueNotes
Active cart discounts without a code100Configurable
Total cart discounts per project (active + inactive)platform limit appliessee limits docs
Cart discounts requiring codesplatform limit appliessee limits docs
Cart discounts per Discount Group100
Discount Groups per project100
Cart discounts per discount code10
Discount codes per cart10
Active product discounts500

Store-Specific Discount Codes

Store-specific discount codes link a discount code to store-aware cart discounts so the promotion only applies in the intended store context.

  • No additional implementation work — associate the discount code with a cart discount that has a store assignment. The code automatically inherits the store context.
  • The store field on cart discounts is an array — a single cart discount can apply across up to 500 stores.
  • There is no project-level toggle. The feature activates implicitly when a code is associated with a store-specific cart discount.
  • Use case: merchants using stores as regional or brand partitions — prevents discount code conflicts across store boundaries and simplifies regional campaign management.
dynamic-shipping-costs.md

Handling Dynamic Shipping Costs

Overview

Consider a scenario where the shipping cost is computed by a 3rd party logistics provider, therefore at the time of cart creation the precise shipping cost is unknown.

There are two methods to handle this:

  1. Method 1: Using cart freeze & the SetCustomShippingMethod API call
  2. Method 2: Using Order Edits

Method 1: Using cart freeze & the SetCustomShippingMethod API call

One approach is to create a cart with some line items, a shipping address, a $0 shipping method and freezing the cart. After the cart is frozen, you would call the 3rd party logistic provider with cart details and obtain the precise shipping cost.
The initial $0 shipping method is associated using the setShippingMethod API call (a regular shipping method). Once the precise shipping cost is known, you would associate it to the frozen cart using the setCustomShippingMethod API call and then place your order.

Structure of the $0 shipping method (predicates may be used as required)

{
            "id": "d07b163f-f76d-41c9-8aa3-b9de2d4529ec",
            "version": 1,
            "versionModifiedAt": "2024-12-08T19:16:38.773Z",
            "createdAt": "2024-12-08T19:16:38.773Z",
            "lastModifiedAt": "2024-12-08T19:16:38.773Z",
            "lastModifiedBy": {
                "isPlatformClient": true,
                "user": {
                    "typeId": "user",
                    "id": "{user-id}"
                }
            },
            "createdBy": {
                "isPlatformClient": true,
                "user": {
                    "typeId": "user",
                    "id": "{user-id}"
                }
            },
            "name": "Example Dynamic Delivery",
            "localizedName": {
                "en-US": "Example Dynamic Delivery"
            },
            "localizedDescription": {
                "en-US": "Example Dynamic Delivery"
            },
            "taxCategory": {
                "typeId": "tax-category",
                "id": "193570d0-555f-4be2-a172-6ff5639952e6"
            },
            "zoneRates": [
                {
                    "zone": {
                        "typeId": "zone",
                        "id": "a77a57f0-01bb-4e6e-99af-08c0e6d0b5a6"
                    },
                    "shippingRates": [
                        {
                            "price": {
                                "type": "centPrecision",
                                "currencyCode": "USD",
                                "centAmount": 0,
                                "fractionDigits": 2
                            },
                            "tiers": []
                        }
                    ]
                }
            ],
            "active": true,
            "isDefault": false,
            "predicate": "1 = 1",
            "key": "example-dynamic-delivery",
            "references": []
        }

Cart Creation

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "key": "example-cart-key-1",
  "currency": "USD",
  "country": "US",
  "shippingMode": "Single",
  "lineItems": [
    {
      "sku": "SCM-02",
      "quantity": 1
    },
    {
      "sku": "MCP-01",
      "quantity": 1
    }
  ]
}
'

Setting the shipping address on the cart

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "version": 5,
    "actions": [
        {
            "action" : "setShippingAddress",
            "address" : {
              "key" : "example-address-key",
              "title" : "My Address",
              "salutation" : "Mr.",
              "firstName" : "Jane",
              "lastName" : "Smith",
              "streetName" : "Main",
              "streetNumber" : "123",
              "postalCode" : "10001",
              "city" : "New York",
              "state" : "NY",
              "country" : "US"
            }
          }
    ]
}'

Associating the $0 shipping method to the cart (since the exact shipping cost is unknown)

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "version": 8,
    "actions": [
        {
            "action" : "setShippingMethod",
            "shippingMethod" : {
              "id" : "d07b163f-f76d-41c9-8aa3-b9de2d4529ec",
              "typeId" : "shipping-method"
            }
          }
    ]
}'

Freezing the cart

The freeze must use the SoftFreeze strategy, because the subsequent dynamic-rate update (setCustomShippingMethod) is a shipping method update. Under the HardFreeze strategy, shipping method updates are blocked, so this method would silently break.
curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "version": 10,
    "actions": [
        {
            "action" : "freezeCart"
          }
    ]
}'

Associating the new shipping cost to the cart using the SetCustomShippingMethod API call

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "version": 13,
    "actions": [
        {
            "action" : "setCustomShippingMethod",
            "shippingMethodName" : "myCustomShippingMethod",
            "shippingRate" : {
              "price" : {
                "currencyCode" : "USD",
                "centAmount" : 990
              }
            },
            "taxCategory" : {
              "id" : "193570d0-555f-4be2-a172-6ff5639952e6",
              "typeId" : "tax-category"
            }
          }
    ]
}'

Converting the frozen cart to an order

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/orders' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "cart" : {
    "id" : "{cart-id}",
    "typeId" : "cart"
  },
  "version" : 13
}'

{
    "type": "Order",
    "id": "{order-id}",
   ///
    "shippingInfo": {
        "shippingMethodName": "myCustomShippingMethod",
        "price": {
            "type": "centPrecision",
            "currencyCode": "USD",
            "centAmount": 990,
            "fractionDigits": 2
        },
        "shippingRate": {
            "price": {
                "type": "centPrecision",
                "currencyCode": "USD",
                "centAmount": 990,
                "fractionDigits": 2
            },
            "tiers": []
        }///
        "shippingMethodState": "MatchesCart"
    },
    "shippingAddress": {
        "title": "My Address",
        "salutation": "Mr.",
        "firstName": "Jane",
        "lastName": "Smith",
        "streetName": "Main",
        "streetNumber": "123",
        "postalCode": "10001",
        "city": "New York",
        "state": "NY",
        "country": "US",
        "key": "example-address-key"
    },
    "shipping": [],
    "lineItems": [///],
    "customLineItems": [],
    "transactionFee": true,
    "discountCodes": [],
    "directDiscounts": [],
    "cart": {
        "typeId": "cart",
        "id": "{cart-id}"
    },
    "itemShippingAddresses": [],
    "refusedGifts": []
}

Method 2: Using Order Edits

Another approach is to create an order out of the cart after associating it with a $0 shipping method, and then using the OrderEdit functionality to update the shipping costs once they are known.

Cart creation

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "key": "example-cart-key-2",
  "currency": "USD",
  "country": "US",
  "shippingMode": "Single",
  "lineItems": [
    {
      "sku": "SCM-02",
      "quantity": 1
    },
    {
      "sku": "MCP-01",
      "quantity": 1
    }
  ]
}
'

Setting the shipping address on the cart

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id-2}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
    "version": 5,
    "actions": [
        {
            "action" : "setShippingAddress",
            "address" : {
              "key" : "example-address-key",
              "title" : "My Address",
              "salutation" : "Mr.",
              "firstName" : "Jane",
              "lastName" : "Smith",
              "streetName" : "Main",
              "streetNumber" : "123",
              "postalCode" : "10001",
              "city" : "New York",
              "state" : "NY",
              "country" : "US"
            }
          }
    ]
}'

Associating the $0 shipping method to the cart (since the exact shipping cost is unknown)

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/carts/{cart-id-2}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {access_token}' \
--data '{
    "version": 8,
    "actions": [
        {
            "action" : "setShippingMethod",
            "shippingMethod" : {
              "id" : "d07b163f-f76d-41c9-8aa3-b9de2d4529ec",
              "typeId" : "shipping-method"
            }
          }
    ]
}'

Convert the cart to an order

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/orders' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {access_token}' \
--data '{
  "cart" : {
    "id" : "{cart-id-2}",
    "typeId" : "cart"
  },
  "version" : 8
}'

Create an OrderEdit draft and set the appropriate CustomShippingMethod in it

curl --location 'https://api.us-central1.gcp.commercetools.com/{your-project-key}/orders/edits' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {access_token}' \
--data '{
  "key" : "example-order-edit-key",
  "resource" : {
    "typeId" : "order",
    "id" : "{order-id}"
  },
  "stagedActions" : [ {
  "action": "setCustomShippingMethod",
  "shippingMethodName": "example-dynamic-shipping-update",
  "shippingRate": {
    "price": {
      "currencyCode": "USD",
      "centAmount": 990
    }
  },
  "taxCategory": {
    "typeId": "tax-category",
    "id": "193570d0-555f-4be2-a172-6ff5639952e6"
  }
} ],
  "comment" : "updated shipping costs"
}'

Following this, apply the order edit.

Accept the order edit preview and proceed with completing the order edit.

financing-options.md

Financing Options

How to setup financing options to be displayed in the PDP

Q: Need to allow display of financing options for specific products:
  • 0% financing for 18 months
  • 0% financing for 12 months
  • etc.

Need to include specific text and additional information about each financing option.

A: There are two ways to do it, none of them perfect and each has its own pros and cons. Using product discounts is not a good fit for two reasons: 1) only one product discount is applied and 2) there is no support for custom fields to add the necessary information. Two viable options are:

Option 1: Custom Fields on the Price

Setup the financing options as a set of custom fields in the price (one record for each financing option).

Pros:
  • Retrieved with the product information
Cons:
  • Requires setup for each variant and can be confusing to the user
  • For each price, requires one additional price for the period when the financing is available

Option 2: Custom Objects Associated with the Product

Use a set of custom objects associated with the product (same for all variants). Each custom object will contain one financing option's information.

Pros:
  • Can be set at product level
Cons:
  • Need to check the validity period; not enough to expand when the product is retrieved
  • Is maintained in the custom object (via the API or custom app)

Additional Answers

Option 1 alternative: Could be implemented using product discounts with custom fields — however, product discounts do not support custom fields, so this approach is not currently viable.
Option 2 best option: Probably the best option is to use a cart discount.
Con: Cannot be displayed for the PDP (or PLP)
Pros:
  • Can be defined for a number of products (by category, brand, etc.)
  • Validity period is set for the discount
  • Supports custom fields to save all the necessary information for each financing option
fixed-price-combo.md

Fixed Price Combo Discount

Part 1: Fixed Price Combo Discount

Scenario

The customer wants to provide fixed price discounts when specific items are added to the cart. The net effect would be to provide discounted price for each item in a combo or bundle. The customer does not want to create static bundles for each combination of items.

Create a promotion scheme capable of discounting multiple products by a set amount when added to the cart. In this example fixed price discounts and a discount code will be used.

Two products will be created with different published prices. The discounts will reduce the variant prices to $5 so that the combined price for the two products is $10.

Custom Type

Create a lineItem custom type containing two attributes. The two attributes will be set during the add item calls to the cart API and will also be used in the cart discount predicate. These attributes also can aid in item cleanup.

curl --location 'https://api.us-central1.gcp.commercetools.com/{{project-key}}/types' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {access_token}' \
--data '{
  "key" : "lineItemComboType",
  "name" : {
    "en" : "lineItemComboType"
  },
  "description" : {
    "en" : "LineItem Combo type"
  },
  "resourceTypeIds" : [ "line-item" ],
  "fieldDefinitions" : [ 
      {
        "name" : "isPartOfCombo",
        "label" : {
        "en" : "isPartOfCombo"
        },
        "required" : true,
        "type" : {
        "name" : "Boolean"
        }
    },
    {
        "name" : "comboId",
        "label" : {
        "en" : "comboId"
        },
        "required" : false,
        "type" : {
        "name" : "String"
        }
    }
  ]
}'

Cart Discount

Build a cart discount containing a fixed price discount which reduces the price of the target skus to $5. The discount code required option is selected.

The promotion rule predicate is created to include the custom line item type attributes and the skus for the items (skus could be replaced by categories).

The predicate syntax is:

(custom.isPartOfCombo = true and custom.comboId is defined) and (sku = "simple-sandwich" or sku = "simple-soup")

The cart qualifier is set to apply to all shopping carts without exclusion. This could be set to target specific carts if needed.

Discount Code

The setup for the discount code is straightforward — associate it with the cart discount created above.

Cart Interactions

Items are added to the cart using the custom type defined above. The first combo item is added with the isCombo set to false and the comboId with no value.

curl --location 'https://api.us-central1.gcp.commercetools.com/{{project-key}}/carts/{{}cart-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {access_token}' \
--data '{
    "version": {{version}},
    "actions": [
        {
            "action" : "addLineItem",
            "sku": "simple-soup",
            "quantity" : 1,
            "externalTaxAmount" : {
              "name" : "StandardExternalTaxRate",
              "amount" : 0.19,
              "country" : "US"
            },
            "custom" : {
                "type" : {
                    "key" : "lineItemComboType",
                    "typeId" : "type"
                    },
                "fields" : {
                "isPartOfCombo" : false,
                "comboId" : ""
                }
            }
            
          }
    ]
}'

When the second combo item is added to the cart, the item is added with the isCombo flag set to true and the comboId set to some value (GUID). The request also includes update commands on the first lineItem to set the isCombo attribute to true and the comboID to the new value.

curl --location 'https://api.us-central1.gcp.commercetools.com/{{project-key}}/carts/{{cart-id}}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {access_token}' \
--data '{
    "version": 26,
    "actions": [
        {
            "action" : "setLineItemCustomField",
            "lineItemId" : "{{line-item-id}}",
            "name" : "isPartOfCombo",
            "value" : true
        },
        {
            "action" : "setLineItemCustomField",
            "lineItemId" : "{{line-item-id}}",
            "name" : "comboId",
            "value" : "soup-combo-2"
        },
        {
            "action" : "addLineItem",
            "sku": "simple-sandwich",
            "quantity" : 1,
            "externalTaxAmount" : {
              "name" : "StandardExternalTaxRate",
              "amount" : 0.19,
              "country" : "US"
            },
            "custom": {
                "type" : {
                    "key" : "lineItemComboType",
                    "typeId" : "type"
                    },
                    "fields" : {
                    "isPartOfCombo" : true,
                    "comboId" : "soup-combo-2"
                    }
            }
            
          }
    ]
}'

The comboId would be unique for each pair of combo items. When line items are added with unique custom attribute values they are added as new line items — as opposed to incrementing the quantity of an existing line item. The comboId could also aid in cleanup of items as customers add and remove items from the cart.

When the discount code is added to the cart the discount is applied only to the items with the matching skus, isCombo=true and a comboId value.

Representative Cart Examples

# one set of combo items 
{
  "data": {
    "carts": {
      "results": [
        {
          "id": "{cart-id}",
          "lineItems": [
            {
              "id": "1fb0b0a6-94f5-4cd8-be44-6459d77f588a",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-1"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-soup",
                "price": {
                  "country": "US",
                  "key": "simple-soup",
                  "value": {
                    "centAmount": 800,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "id": "04357fa1-204c-44a3-9670-e2c0efebd726",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-1"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-sandwich",
                "price": {
                  "country": null,
                  "key": "default-price",
                  "value": {
                    "centAmount": 1000,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            }
          ],
          "totalPrice": {
            "centAmount": 1000,
            "currencyCode": "USD",
            "fractionDigits": 2
          },
          "discountCodes": [
            {
              "discountCode": {
                "id": "0788fbc9-d0d3-46b9-801e-826b18404636",
                "code": "10$-SOUP_AND_SANDWICH"
              }
            }
          ]
        }
      ]
    }
  }
}
# same cart build another combo. 1 discounted combo. 1 new item 
{
  "data": {
    "carts": {
      "results": [
        {
          "id": "{cart-id}",
          "lineItems": [
            {
              "id": "1fb0b0a6-94f5-4cd8-be44-6459d77f588a",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-1"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-soup",
                "price": {
                  "country": "US",
                  "key": "simple-soup",
                  "value": {
                    "centAmount": 800,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "id": "04357fa1-204c-44a3-9670-e2c0efebd726",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-1"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-sandwich",
                "price": {
                  "country": null,
                  "key": "default-price",
                  "value": {
                    "centAmount": 1000,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "id": "47c96d63-5a19-4b36-a4a2-06231aa530c4",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": false
                  },
                  {
                    "name": "comboId",
                    "value": ""
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-soup",
                "price": {
                  "country": "US",
                  "key": "simple-soup",
                  "value": {
                    "centAmount": 800,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": []
            }
          ],
          "totalPrice": {
            "centAmount": 1800,
            "currencyCode": "USD",
            "fractionDigits": 2
          },
          "discountCodes": [
            {
              "discountCode": {
                "id": "0788fbc9-d0d3-46b9-801e-826b18404636",
                "code": "10$-SOUP_AND_SANDWICH"
              }
            }
          ]
        }
      ]
    }
  }
}
# same cart. 2 combos discounted
{
  "data": {
    "carts": {
      "results": [
        {
          "id": "{cart-id}",
          "lineItems": [
            {
              "id": "1fb0b0a6-94f5-4cd8-be44-6459d77f588a",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-1"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-soup",
                "price": {
                  "country": "US",
                  "key": "simple-soup",
                  "value": {
                    "centAmount": 800,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "id": "04357fa1-204c-44a3-9670-e2c0efebd726",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-1"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-sandwich",
                "price": {
                  "country": null,
                  "key": "default-price",
                  "value": {
                    "centAmount": 1000,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "id": "47c96d63-5a19-4b36-a4a2-06231aa530c4",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-2"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-soup",
                "price": {
                  "country": "US",
                  "key": "simple-soup",
                  "value": {
                    "centAmount": 800,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            },
            {
              "id": "a4aace89-b68a-4aba-b601-4c2e7f19496c",
              "custom": {
                "customFieldsRaw": [
                  {
                    "name": "isPartOfCombo",
                    "value": true
                  },
                  {
                    "name": "comboId",
                    "value": "soup-combo-2"
                  }
                ]
              },
              "quantity": 1,
              "variant": {
                "sku": "simple-sandwich",
                "price": {
                  "country": null,
                  "key": "default-price",
                  "value": {
                    "centAmount": 1000,
                    "currencyCode": "USD",
                    "fractionDigits": 2
                  }
                }
              },
              "discountedPricePerQuantity": [
                {
                  "quantity": 1,
                  "discountedPrice": {
                    "value": {
                      "centAmount": 500,
                      "currencyCode": "USD",
                      "fractionDigits": 2
                    },
                    "includedDiscounts": [
                      {
                        "discount": {
                          "key": "soup-sandwidth-combo-sandwich-discount-dup",
                          "name": null
                        }
                      }
                    ]
                  }
                }
              ]
            }
          ],
          "totalPrice": {
            "centAmount": 2000,
            "currencyCode": "USD",
            "fractionDigits": 2
          },
          "discountCodes": [
            {
              "discountCode": {
                "id": "0788fbc9-d0d3-46b9-801e-826b18404636",
                "code": "10$-SOUP_AND_SANDWICH"
              }
            }
          ]
        }
      ]
    }
  }
}

Part 2: Buy 2 or More of a Specific Product at a Fixed Discounted Price

This document explains how to set up discounts where customers can buy two or more ProductVariants of a specific Product at a fixed discounted price each, using the Buy/Get Cart Discount functionality.
The discount will apply only when 2 or more ProductVariants of the same Product are present in a customer's cart. While the feature supports relative, absolute and fixed price discounts, this document focuses on fixed price discounts, i.e., selling eligible items at a fixed price.

Key Requirements

  • Product Based Trigger: Discount triggers only when 2 or more ProductVariants of the same Product are present in a customer's cart.
  • Product Based Target: The discount applies to ProductVariants of the same Product present in the customer's cart.
  • Discount Type: This is a fixed price discount, i.e., eligible ProductVariants are sold at a fixed price determined by the user, and it applies an unlimited number of times.

Solution Overview

The Buy/Get Cart Discount feature allows for setting conditions and outcomes for discounts, making it ideal for implementing these scenarios.

Configure the Cart Discount for All Carts

Configure the discount to apply to all carts using the Merchant Center.

Define the Trigger

Specify the desired Product key required to trigger the discount. For example, if you want to trigger this discount only when two or more of the same Product are in the cart:
"triggerPattern": [
    {
        "type": "CountOnLineItemUnits",
        "predicate": "product.key = \"rye-whiskey-glass\"",
        "minCount": 2
    }
]
Note: While this example focuses on using the Product key as a predicate, you have a lot of flexibility in building the trigger pattern predicate. For a complete list, refer to the LineItem field identifiers documentation.

Set the Discount Target

Use the targetPattern to apply the discount to the qualifying Cart lineItems:
"targetPattern": [
    {
        "type": "CountOnLineItemUnits",
        "predicate": "product.key = \"rye-whiskey-glass\"",
        "minCount": 1,
        "excludeCount": 0
    }
]
Note: While setting the targetPattern, you have the option of configuring the excludeCount setting.
The excludeCount feature ensures that items used to trigger a discount (e.g., "Buy 3 get 2 at a special price") are excluded from receiving the discount themselves. Once a discount iteration is applied to a cart, the excluded and discounted items from that iteration are locked in and won't be considered for subsequent iterations of the same discount. If there aren't enough items left to meet the trigger condition, the discount stops.

Set Discount Value and Distribution

Discount of this type may be absolute ($ off), relative (% off) or a fixed price, and you can decide if the discount should be:

  • Distributed evenly across all eligible items
  • Applied individually to each eligible item
  • Distributed proportionately across all eligible items
**Discount Distribution is configurable only when Discount Type is either "Amount Off" or "Fixed Price". This setting does not apply to relative discounts (i.e., "Percentage Off").

For the purpose of this use case, configure the discount as a fixed price with individual application:

"value": {
    "type": "fixed",
    "money": [
        {
            "type": "centPrecision",
            "currencyCode": "EUR",
            "centAmount": 3500,
            "fractionDigits": 2
        },
        {
            "type": "centPrecision",
            "currencyCode": "GBP",
            "centAmount": 3500,
            "fractionDigits": 2
        },
        {
            "type": "centPrecision",
            "currencyCode": "USD",
            "centAmount": 3500,
            "fractionDigits": 2
        }
    ],
    "applicationMode": "IndividualApplication"
}

Complete Cart Discount JSON

{
    "id": "{cart-discount-id}",
    "version": 4,
    "versionModifiedAt": "2025-01-21T14:41:34.491Z",
    "lastMessageSequenceNumber": 1,
    "createdAt": "2025-01-21T14:31:16.931Z",
    "lastModifiedAt": "2025-01-21T14:41:34.491Z",
    "lastModifiedBy": {
        "isPlatformClient": true,
        "user": {
            "typeId": "user",
            "id": "{user-id}"
        }
    },
    "createdBy": {
        "isPlatformClient": true,
        "user": {
            "typeId": "user",
            "id": "{user-id}"
        }
    },
    "value": {
        "type": "fixed",
        "money": [
            {
                "type": "centPrecision",
                "currencyCode": "EUR",
                "centAmount": 3500,
                "fractionDigits": 2
            },
            {
                "type": "centPrecision",
                "currencyCode": "GBP",
                "centAmount": 3500,
                "fractionDigits": 2
            },
            {
                "type": "centPrecision",
                "currencyCode": "USD",
                "centAmount": 3500,
                "fractionDigits": 2
            }
        ],
        "applicationMode": "IndividualApplication"
    },
    "cartPredicate": "1 = 1",
    "target": {
        "type": "pattern",
        "triggerPattern": [
            {
                "type": "CountOnLineItemUnits",
                "predicate": "product.key = \"rye-whiskey-glass\"",
                "minCount": 2
            }
        ],
        "targetPattern": [
            {
                "type": "CountOnLineItemUnits",
                "predicate": "product.key = \"rye-whiskey-glass\"",
                "minCount": 1,
                "excludeCount": 0
            }
        ],
        "selectionMode": "MostExpensive"
    },
    "name": {
        "en-US": "example-cart-discount",
        "en-GB": "",
        "de-DE": ""
    },
    "description": {
        "en-US": "example-cart-discount",
        "en-GB": "",
        "de-DE": ""
    },
    "stackingMode": "Stacking",
    "isActive": true,
    "requiresDiscountCode": false,
    "sortOrder": "0.99999999",
    "references": [],
    "stores": [],
    "key": "example-cart-discount"
}
high-precision-money.md

HighPrecisionMoney

When to use (and when not to)

HighPrecision Money provides greater decimal precision than the BaseMoney type. Allowing for non-traditional currencies (for example, Crypto Currencies) as well as product pricing (for example, Gasoline).

Introducing HighPrecisionMoney into a project can add risk and complexity. The business value of supporting HighPrecisionMoney should be evaluated against this risk and complexity, including:

  • Added application logic to differentiate between BaseMoney and HighPrecisionMoney types
  • Potential for rounding conflicts within the cart and upstream/downstream systems
  • Not all resources/features which support Money support HighPrecisionMoney (for example absolute discount values)

Considerations

  • commercetools APIs (and therefore Merchant Center) will convert HighPrecisionMoney amounts with precision less than or equal to the currency's default precision to BaseMoney.

    • For example, if a product price is created with a HighPrecisionMoney type with a fractionDigits value of 3 and preciseAmount of 123.450, the result will be a BaseAmount with centAmount of 123.45.
    • This means that even if all products will only have HighPrecisionMoney prices, storefront applications will need to support BaseMoney prices in case of rounded prices.

  • Absolute discount values (Product Discounts & Cart Discounts) don't yet support HighPrecisionMoney

Implementation

HighPrecisionMoney and BaseMoney prices can coincide within a Variant's price array:
"prices": [
    {
        "id": "497a7774-fb4f-4a77-977b-a1f5a3d23904",
        "value": {
            "type": "centPrecision",
            "currencyCode": "USD",
            "centAmount": 350,
            "fractionDigits": 2
        }
    },
    {
        "id": "6b183aa7-7b46-4c65-b360-ef7f30bd8d73",
        "value": {
            "type": "highPrecision",
            "currencyCode": "USD",
            "preciseAmount": 123456,
            "fractionDigits": 7
        },
        "key": "high-precision",
        "country": "US"
    }
],
Price dimension differentiation requirements still apply — note the "country" dimension on the highPrecision price above.
Price Selection Response
// BaseMoney
"price": {
    "id": "497a7774-fb4f-4a77-977b-a1f5a3d23904",
    "value": {
        "type": "centPrecision",
        "currencyCode": "USD",
        "centAmount": 350,
        "fractionDigits": 2
    }
}
// HighPrecisionMoney
"price": {
    "id": "6b183aa7-7b46-4c65-b360-ef7f30bd8d73",
    "value": {
        "type": "highPrecision",
        "currencyCode": "USD",
        "preciseAmount": 123456,
        "fractionDigits": 7
    },
    "key": "high-precision",
    "country": "US"
}
Notice in both cases the selected price is returned in the price.value block. The difference is the presence of preciseAmount and fractionDigits attributes in the case of HighPrecisionMoney.
GraphQL Solution

Since GraphQL requires results to match expected Types, an implementation can include fragments mapping both BaseMoney and HighPrecisionMoney types.

# In result definition where price is used, 
# include fragment definitions for both Money types
price(currency: $currency) {
  value {
    ...money
    ...preciseMoney
    __typename
  }
}

# Create fragments for each BaseMoney and HighPrecision types
fragment money on BaseMoney {
  type
  currencyCode
  centAmount
  fractionDigits
}

fragment preciseMoney on HighPrecisionMoney {
  type
  currencyCode
  centAmount
  fractionDigits
  preciseAmount
}

Price Rounding Mode

commercetools supports configurable price rounding at the cart and project level via priceRoundingMode. This directly affects ERP reconciliation when CT totals must match external systems exactly.
Configuration locations:
  • CartDraft.priceRoundingMode — per-cart override at creation time
  • Project Settings — default for all carts in the project
  • Also applies to: Order, OrderImportDraft, Quote, QuoteRequest
Available modes:
ModeBehavior
HalfEven (banker's rounding)Rounds 0.5 to the nearest even digit; minimizes cumulative rounding error over many transactions
HalfUpStandard rounding — 0.5 always rounds up
HalfDown0.5 always rounds down
When to use: Choose HalfEven when ERP or financial systems use banker's rounding (common in European finance). Choose HalfUp for standard retail rounding. Mismatched rounding modes between CT and downstream/ERP systems cause reconciliation discrepancies — agree on the mode before go-live.
item-substitutes.md

Item Substitutes

Q&A

Q: Does commercetools provide any out-of-the-box (OOB) feature to support item substitution?

A: Yes, you can use product references.
Define an attribute on a ProductType that refers to another product or SKU as a substitute. If there is a need for an item substitute and a product is referencing it, you can add the substitute item to the cart by switching between products — since the original product refers to the substitute.
Implementation pattern:
  1. Add an attribute to the ProductType of type reference (pointing to product typeId) or set of references.
  2. Populate this attribute with the ID(s) of the substitute product(s).
  3. When the primary product is out of stock or unavailable, the BFF/storefront reads the substitute reference attribute and presents the substitute product(s) to the customer.
  4. The customer (or the system, in automated substitution scenarios) can then add the substitute to the cart in place of the original item.
limited-time-pricing.md

Pattern for Limited Time Pricing

Imagine you're planning a seasonal promotion on your e-commerce platform, aiming to temporarily lower prices on select products to boost sales. To streamline this process, you need a way to import multiple price adjustments at once, each with a definite validity period — such as one week.

This guide explains how to handle such scenarios effectively using commercetools import API, while utilizing the validFrom and validUntil properties of embeddedPrices and standalonePrices.

Creating the Limited Time Pricing Record

Consider a product that is usually priced at $16.99 on your website. You want to offer an end-of-season sale on this product that is valid for a week starting January 26th, 2025.

In order to achieve this, you can build a price reduction import object as shown below:

Using Embedded Prices

When using embedded prices, create the following embedded price import resource specifying the validity range (from January 26th through February 1st in our example), the product and product variant keys, and the temporary price ($9.99 in our case):
{
    "key" : "embedded-tpr-jan-2025",
    "country" : "US",
    "validFrom" : "2025-01-26T00:00:00.000Z",
    "validUntil" : "2025-02-01T00:00:00.000Z",
    "productVariant" : {
      "typeId" : "product-variant",
      "key" : "ISP-01"
    },
    "product" : {
      "typeId" : "product",
      "key" : "ivory-plate"
    },
    "value" : {
      "type" : "centPrecision",
      "currencyCode" : "USD",
      "centAmount" : 999
    }
  }
For additional details, refer to the documentation: Importing Embedded Prices

Using Standalone Prices

When using standalone prices, create the following standalone price import resource specifying the validity range, the product variant SKU, and the temporary price ($9.99 in our case):
{
    "key" : "standalone-tpr-jan-2025",
    "sku" : "ISP-01",
    "value" : {
      "type" : "centPrecision",
      "currencyCode" : "USD",
      "centAmount" : 999
    }
  }
For additional details, refer to the documentation: Importing Standalone Prices

Uploading multiple price records using the Import API

As described in the commercetools documentation, up to 20 price records (either embedded prices or standalone prices) can be uploaded using the Import API at a time. At a high level, this process entails:

Creating an Import Container

The first step of starting the import process is creating an import container using the following API call:

curl --location 'https://import.us-central1.gcp.commercetools.com/{your-project-key}/import-containers' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "key" : "order-import-container-embedded-prices"
}'

{
    "key": "order-import-container-embedded-prices",
    "version": 1,
    "createdAt": "2025-01-25T04:34:22.248Z",
    "lastModifiedAt": "2025-01-25T04:34:22.248Z"
}
For additional information on import containers and best practices, refer to this document.

Uploading prices to the Import Container

Once the import container is created, use the following API call to upload the reduced prices with dates to the import container:

Embedded Prices:
curl --location 'https://import.us-central1.gcp.commercetools.com/{your-project-key}/prices/import-containers/order-import-container-embedded-prices' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "type" : "price",
  "resources" : [ {
    "key" : "tpr-jan-2025",
    "country" : "US",
    "validFrom" : "2025-01-26T00:00:00.000Z",
    "validUntil" : "2025-02-01T00:00:00.000Z",
    "productVariant" : {
      "typeId" : "product-variant",
      "key" : "ISP-01"
    },
    "product" : {
      "typeId" : "product",
      "key" : "ivory-plate"
    },
    "value" : {
      "type" : "centPrecision",
      "currencyCode" : "USD",
      "centAmount" : 999
    }
  }]
}'

{
    "operationStatus": [
        {
            "operationId": "{import-operation-id}",
            "state": "processing"
        }
    ]
}
Using the operationId returned in the above response, you may use the following API call to determine the status of your import operation:
curl --location 'https://import.us-central1.gcp.commercetools.com/{your-project-key}/import-operations/{import-operation-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data ''
Standalone Prices:
curl --location 'https://import.us-central1.gcp.commercetools.com/{your-project-key}/standalone-prices/import-containers/order-import-container-embedded-prices' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data '{
  "type" : "standalone-price",
  "resources" : [ {
    "key" : "standalone-tpr-jan-2025",
    "sku" : "ISP-01",
    "value" : {
      "type" : "centPrecision",
      "currencyCode" : "USD",
      "centAmount" : 999
    }
  } ]
}'

{
    "operationStatus": [
        {
            "operationId": "{import-operation-id}",
            "state": "processing"
        }
    ]
}
Using the operationId returned in the above response, you may use the following API call to determine the status of your import operation:
curl --location 'https://import.us-central1.gcp.commercetools.com/{your-project-key}/import-operations/{import-operation-id}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer' \
--data ''

Considerations when setting the 'validFrom' & 'validUntil' values

When setting a limited-time price, keep in mind that it will temporarily override the product's regular price. For example, SKU ISP-01 will be available for $9.99 from January 26, 2025, to February 2, 2025.

Once this period ends, the SKU will return to its regular price of $16.99.

**In certain business scenarios, multiple alternate prices for the same product may need to be active simultaneously. For example, a "Cyber Monday" price might apply to an item already on temporary clearance. These situations require case-by-case handling and are beyond the scope of this document.

Limited Time Pricing vs. Product Discounts

Product Discounts provide an alternative way to temporarily reduce a product's price based on specific conditions or predicates. These discounts show up as a strikethrough price in the Merchant Center, making them visually distinct.
If your business relies on an external pricing system to manage both regular and temporary product prices, the Limited Time Pricing approach in this document may be a better fit than Product Discounts.
Additionally, if downstream systems (such as your Order Management System) require information about whether a product was sold at a reduced price (i.e., non-discounted reduced price), you can extend the Limited Time Pricing pattern by leveraging Custom Fields. Use this to capture additional details, such as the marketing campaign associated with the special price, ensuring that data is preserved in the customer's order history.
payments.md

Payments — Object Model, Transactions, and Integration Patterns


Key Principles

  • CT records payment state; the PSP does the actual financial processing. commercetools is not a payment processor. The Payment object is a ledger that tracks the state of funds received/refunded. The actual charge flows through a PSP (Stripe, Adyen, Braintree, etc.) via a PSP-specific integration.
  • Transactions should only be set to Success after PSP confirmation. Never optimistically mark a transaction as Success — wait for the PSP callback/webhook before updating the transaction state.
  • interfaceId is immutable once set. The interfaceId field links the CT Payment to the PSP's transaction identifier. It cannot be changed after it is first recorded. Store it correctly on first write.
  • Payments are referenced by Order or Cart via PaymentInfo. A Payment object exists independently — it is linked to a cart/order through the addPayment update action, which creates a reference in the cart/order's paymentInfo.payments array.

Payment Object Model

Order / Cart
  └─ paymentInfo.payments[] → Payment
                                  ├─ amountPlanned (Money)
                                  ├─ interfaceId (String, optional, immutable once set)
                                  ├─ paymentMethodInfo (optional)
                                  │     ├─ paymentInterface: "CreditCardPayment"
                                  │     ├─ method: "creditCard"
                                  │     └─ name: { "en": "creditCard" }
                                  ├─ paymentStatus (optional)
                                  │     ├─ interfaceCode
                                  │     ├─ interfaceText
                                  │     └─ state (optional)
                                  ├─ transactions[] (array)
                                  └─ custom (CustomFields, optional)
amountPlanned — the amount this payment intends to receive from the customer. Usually matches the cart or order gross total.
interfaceId — the identifier used by the PSP to track this payment. Should be set when the PSP transaction identifier is known. Immutable once set — do not update.
paymentMethodInfo — stores the payment interface name, method (e.g., creditCard, paypal), and a localized display name.
paymentStatus — records PSP-specific status codes (interfaceCode, interfaceText) and optionally a CT state machine state.

Transactions

Transactions are an array on the Payment object that tracks the full lifecycle of a payment:

Transaction Types

TypeDescription
AuthorizationFunds reserved with PSP (hold)
CancelAuthorizationAuthorization voided before capture
ChargeFunds captured / debited
RefundFunds returned to customer
ChargebackCustomer-initiated reversal via bank/card network

Transaction States

StateDescription
InitialTransaction created, not yet submitted to PSP
PendingSubmitted to PSP, awaiting confirmation
SuccessPSP confirmed the transaction completed
FailurePSP confirmed the transaction failed

Transaction Fields

  • type — TransactionType (see above)
  • amount — Money
  • interactionId — PSP's transaction identifier for this specific transaction (optional; helps correlate interfaceInteractions)
  • state — TransactionState (see above)
  • custom — CustomFields for additional PSP-specific data (masked card number, card brand, etc.)

Payment Intent Action Fields

  • merchantReference (on all Payment Intent actions: capture, refund, cancel, reverse): A string field for passing custom references to the PSP at action time. Used for ERP reconciliation — pass the ERP order number, invoice ID, or PO number so PSP transaction records carry the matching reference.
  • transactionId (on Refund Payment action): Targets a specific capture transaction for the refund. Required in multi-capture payment scenarios to avoid ambiguous refund application.
"transactions": [
  {
    "id": "0708a1d3-7055-...",
    "timestamp": "2021-06-21T16:13:02.667Z",
    "type": "Authorization",
    "amount": {
      "type": "centPrecision",
      "currencyCode": "USD",
      "centAmount": 7397,
      "fractionDigits": 2
    },
    "interactionId": "psp-txn-id",
    "state": "Success"
  }
]

My Payments Endpoint

The /me/payments endpoint creates and provides access to payments scoped to a specific customer.
  • Requires an access token from the password flow or anonymous session flow — not a client credentials token
  • Returns a limited subset of Payment fields (MyPayment type): id, version, customer reference, anonymousId, amountPlanned, paymentMethodInfo, transactions, custom
  • MyPaymentDraft auto-populates the customer field (password flow) or anonymousId (anonymous flow)
Use /me/payments for customer-facing checkout flows. Use the full Payment API (with client credentials) for backend/admin operations.

Payment Flow

The standard PSP integration flow:

Customer → Shop → CTP (create/update Payment) → PSP (send/get Payment Info)
                     ↑                                        |
                     └────────────────────────────────────────┘
                         Transaction state (type, state update)
  1. Customer enters checkout and selects payment method
  2. Shop calls CT to create initial Payment resource
  3. CT returns Payment with id and optional PSP transaction identifier
  4. Shop sends payment info to PSP (card details via PCI-compliant iframe/element)
  5. PSP processes and returns confirmation
  6. Shop (or PSP webhook) updates CT Payment with final transaction state

API Extension Flow for Payments

A more secure pattern using an API Extension to handle PSP token creation server-side:

  1. Customer enters checkout, begins payment option selection
  2. API request sent to create initial Payment resource in CT
  3. API Extension is triggered by Payment creation event
  4. Extension calls custom cloud function (AWS Lambda, GCP Function, etc.)
  5. Cloud function calls PSP to create new transaction / obtain PCI-compliant token
  6. PSP returns response with transaction identifier
  7. Extension updates CT Payment with PSP transaction identifier via updateActions
  8. Updated Payment resource (with PSP identifier) returned to shop
  9. Customer enters card details — auth/capture sent directly to PSP via PCI-compliant form
  10. PSP processes and returns transaction response to shop
  11. PSP asynchronously pushes payment update events to a PSP Notification Microservice
  12. Notification Microservice calls CT Payment API to update transaction states
Why the Extension approach: The PSP transaction token is created server-side (in the cloud function), never exposing PSP credentials to the browser. PCI compliance is maintained because card details go directly from the customer's browser to the PSP.

commercetools Checkout (Out-of-the-Box UI)

commercetools offers a Checkout product — a hosted UI component that handles the full checkout flow including PSP integration.
High-level flow:
  1. Cart and customer token are created/updated in your storefront
  2. Initialize the CA SDK with cart and token
  3. Checkout UI opens in context (embedded or redirect)
  4. Checkout UI handles all communication with CT and PSPs
  5. After successful transaction, control is returned to the seller site
Two modes:
  • Checkout Mode — full address + payment flow
  • Payment Only Mode — payment widget only (for storefronts that handle address collection separately)
Supported PSPs: Stripe, PayPal, Apple Pay, and others (via CT Checkout native integrations). The Checkout component handles PCI-compliant card collection, PSP communication, and updates CT Payment objects automatically.

Use CT Checkout when you want to minimize custom PSP integration work. Build a custom payment integration when you need full control over the checkout UX or use a PSP not natively supported.


PaymentMethod Resource (PSP Config Store)

The PaymentMethod resource (/payment-methods) stores reusable PSP payment method configurations. This is distinct from the Payment resource, which tracks payment transactions and state:
  • PaymentMethod stores PSP-specific configuration data (e.g., saved card tokens, PSP method identifiers) for reuse across sessions — enables "stored payment methods" patterns (customers saving cards for future purchases)
  • Payment remains the transaction ledger (authorize/capture/refund/cancel)
  • Requires customerId on the Cart before associating stored payment methods
Key gotcha: You cannot attach a stored PaymentMethod to a cart unless the cart already has a customerId. Anonymous carts must be converted (customer logged in or guest converted) before stored payment methods can be used.
price-functions.md

Dynamic Shipping Rate Calculation using Functions

Overview

While you can build an external calculation and set it on the cart, it is possible to dynamically calculate shipping rates using tiered rates, cart score and functions.

Documentation

Shipping Rate Control Setup

In Merchant Center set the shipping rate control to Cart Score.
Configure shipping rate tiers and use Function as opposed to Fixed Amount. Functions represent an equation that takes an input x (cart score) and computes a rate which is applied to the cart. In this example, the cart score passed to the function represents the cart total.

A set of rate tiers representing a function that calculates a reduced shipping rate as the cart score (cart total) increases. The function can be as simple or as complex as required for the use case.

Setting the Shipping Rate Score

To set the shipping rate score, call setShippingRateInput, passing the score (cart total) as an integer. When set, commercetools will apply the function and set the shipping rate on the cart.
curl --location 'https://api.us-central1.gcp.commercetools.com/<project-key/carts/<cart-id>' \
--header 'Content-Type: application/json' \--header 'Authorization: Bearer {access_token}' \
--data '{"version": 42,"actions": [
	{"action" : "setShippingRateInput","shippingRateInput" : 
		{"type" : "Score","score" : 10000}
	}
]
}'

Example Response: Shipping Rate Applied by Function

After running the function, the corresponding shipping rate is set on the cart. Notice that the rate is set to 1000 USD (pennies) based on the function calculation.

...
"shippingInfo": {
        "shippingMethodName": "example-shipping-method",
        "price": {
            "type": "centPrecision",
            "currencyCode": "USD",
            "centAmount": 1000,
            "fractionDigits": 2
        },
        "shippingRate": {
            "price": {
                "type": "centPrecision",
                "currencyCode": "USD",
                "centAmount": 800,
                "fractionDigits": 2
            },
            "tiers": [
                {
                    "type": "CartScore",
                    "score": 19,
                    "priceFunction": {
                        "function": "(x / 30)",
                        "currencyCode": "USD"
                    }
                },
                {
                    "type": "CartScore",
                    "score": 49,
                    "priceFunction": {
                        "function": "(x / 20)",
                        "currencyCode": "USD"
                    }
                },
                {
                    "type": "CartScore",
                    "score": 99,
                    "priceFunction": {
                        "function": "(x / 10)",
                        "currencyCode": "USD"
                    }
                }
            ]
        },
        "deliveries": [],
        "shippingMethod": {
            "typeId": "shipping-method",
            "id": "ec937cdc-5fe2-4ccc-a4bb-ace26fb9a44d"
        },
        "shippingMethodState": "MatchesCart"
    },
...

How the Tiers Work

In this example:

Score RangeFunctionEffect
0–19x / 30Higher shipping rate relative to cart value
20–49x / 20Moderate shipping rate
50–99x / 10Lower shipping rate (rewarding larger carts)
The score of 10000 (representing $100.00 cart total) falls in the 50–99 tier, applying (10000 / 10) = 1000 centAmount = $10.00 shipping. The price.centAmount of 1000 in the response confirms this.
quote-lifecycle.md

Quote Lifecycle


Overview

commercetools implements B2B quoting as a four-entity pipeline, each with its own resource type and state machine. The pipeline converts a buyer's intent into a negotiated, finalised price that can be checked out as an Order.
QuoteRequest  →  StagedQuote  →  Quote  →  Order
  (buyer)          (sales rep)   (sales rep)  (buyer)

The Four Entities

1. QuoteRequest

Who creates it: The buyer (associate acting on behalf of a Business Unit).
What it is: The buyer's initial request for a custom price. It is created from an existing Cart and carries all line items, quantities, and any buyer notes or comments.
State machine:
StateDescription
SubmittedInitial state. The buyer has submitted the request.
AcceptedThe sales rep has accepted the request and is working on it (a StagedQuote will be created).
ClosedThe request is closed (either superseded by a final Quote or abandoned).
RejectedThe sales rep has rejected the request without creating a quote.
CancelledThe buyer has cancelled their own request before it was acted on.
Key fields: quoteRequestState, comment (buyer notes), customer, businessUnit, lineItems (inherited from the source cart).

2. StagedQuote

Who creates it: The sales representative (merchant-side actor).
What it is: The sales rep's working draft of the negotiated quote. Created from a QuoteRequest. The sales rep modifies line item prices, applies discounts, or adjusts quantities on a new Quote Cart associated with the StagedQuote.
State machine:
StateDescription
InProgressThe sales rep is actively working on the quote draft.
SentThe draft has been finalised and a Quote has been created and sent to the buyer.
ClosedNo longer active (e.g. the associated Quote was accepted, rejected, or withdrawn).
Key fields: stagedQuoteState, quotationCart (the cart used to draft the negotiated pricing), sellerComment.

3. Quote

Who creates it: The sales representative, when the StagedQuote is marked Sent.
What it is: The formal, binding quote presented to the buyer. Contains the final negotiated prices. The buyer reviews the Quote and either accepts or rejects it.
State machine:
StateDescription
PendingThe quote has been sent to the buyer and is awaiting their response.
AcceptedThe buyer has accepted the quote (ready to be converted to an Order).
DeclinedThe buyer has declined the quote.
DeclinedForRenegotiationThe buyer has declined but requested a renegotiation (opens a new cycle).
RenegotiationAddressedThe seller has acknowledged the renegotiation request.
WithdrawnThe seller has withdrawn the quote before the buyer acted on it.
Key fields: quoteState, validTo (expiry date), buyerComment (on decline/renegotiation), sellerComment.

4. Order (from Quote)

Who creates it: The buyer, after accepting the Quote.
What it is: A standard commercetools Order, created by calling POST /orders/quotes with the accepted Quote's ID. The Order is created with the negotiated prices locked in from the Quote.
Once created, the Order follows the normal Order state machine (OpenConfirmedComplete, etc.) and fulfilment process.

End-to-End Flow

1. Buyer builds a Cart and submits a QuoteRequest (state: Submitted)
2. Sales rep accepts the QuoteRequest (state: Accepted)
   → Sales rep creates a StagedQuote (state: InProgress)
3. Sales rep adjusts pricing on the StagedQuote's quotation cart
4. Sales rep sends the quote → StagedQuote (state: Sent) + Quote created (state: Pending)
5a. Buyer accepts the Quote (state: Accepted)
    → Buyer creates an Order from the Quote
5b. Buyer declines the Quote (state: Declined / DeclinedForRenegotiation)
    → Quote is closed or renegotiation cycle restarts

Role Summary

ActorActions
Buyer / AssociateCreate QuoteRequest, cancel QuoteRequest, accept/decline Quote, create Order from accepted Quote
Sales Rep / MerchantAccept/reject QuoteRequest, create & edit StagedQuote, create Quote (send to buyer), withdraw Quote

Key API Endpoints

ActionEndpoint
Create QuoteRequestPOST /as-associate/{associateId}/in-business-unit/key={buKey}/quote-requests
Create StagedQuotePOST /staged-quotes
Create QuotePOST /quotes
Create Order from QuotePOST /orders/quotes
Transition Quote statePOST /quotes/{id} with changeQuoteState action

Implementation Notes

  • All buyer-facing mutations (create QuoteRequest, accept/decline Quote, create Order from Quote) must go through the asAssociate API path to enforce BU-scoped permission checks.
  • Quote expiry (validTo) is enforced by the platform: an expired Quote cannot be accepted.
  • A QuoteRequest is always tied to a specific Business Unit; this scopes the quote workflow to that BU's context.
recurring-orders.md

Recurring Orders API — Patterns and Configuration

Covers the full lifecycle of recurring orders in commercetools: RecurrencePolicy setup, recurrence-specific pricing, cart configuration, the critical /orders vs /recurring-orders endpoint distinction, mixed cart behavior, discounting rules, and update/modification patterns.

RecurrencePolicy

A RecurrencePolicy defines the schedule for recurring orders. It must exist before it can be assigned to a line item.

Schedule types

Standard interval — recur every N days or months:
{
  "key": "monthly-subscription",
  "schedule": {
    "type": "standard",
    "value": 1,
    "intervalUnit": "Months"
  }
}
Day of month — recur on a specific day each month:
{
  "key": "first-of-month",
  "schedule": {
    "type": "dayOfMonth",
    "day": 1
  }
}
intervalUnit accepts "Days", "Weeks" (e.g. for weekly subscriptions), or "Months".
Deletion constraint: A RecurrencePolicy cannot be deleted if it is referenced by any line item or cart. Attempting to do so returns a 400 error. Remove all references first.

Recurrence Policy-Specific Prices

Products can have prices tied to a specific RecurrencePolicy — the subscription price can differ from the one-time purchase price.

Embedded price with recurrence policy

Use addPrice on the product with a recurrencePolicy reference:
{
  "action": "addPrice",
  "variantId": 1,
  "price": {
    "value": { "type": "centPrecision", "currencyCode": "USD", "centAmount": 2000 },
    "recurrencePolicy": {
      "typeId": "recurrence-policy",
      "key": "monthly-subscription"
    }
  }
}

Standalone price with recurrence policy

The same recurrencePolicy reference field is supported on standalone prices.
Known limitation: Product Discounts cannot target recurrence policy-specific prices. Apply subscription-specific discounts via Cart Discounts instead.

Cart Configuration — Adding Recurring Line Items

Add a line item with recurrenceInfo to mark it as recurring:
{
  "action": "addLineItem",
  "productId": "...",
  "variantId": 1,
  "quantity": 1,
  "recurrenceInfo": {
    "recurrencePolicy": {
      "typeId": "recurrence-policy",
      "key": "monthly-subscription"
    },
    "priceSelectionMode": "Fixed"
  }
}

priceSelectionMode

ModeBehavior
"Fixed"Price is locked at subscription creation time, including any active discounts. The customer always pays the price that was in effect when they subscribed.
"Dynamic"Platform fetches the current price at each recurrence interval. Price fluctuates with catalog changes.
Mixed Fixed and Dynamic items are allowed in the same cart.

Critical: /orders vs /recurring-orders Endpoint Behavior

This is the most important behavioral difference in the Recurring Orders feature:

EndpointResult
POST /orders (with recurring line items)Creates both a regular order (immediate fulfillment) and a recurring order (future recurrences)
POST /recurring-ordersCreates only a recurring order — no regular order is created
Use POST /orders when the customer's first delivery should ship immediately (the normal subscription sign-up flow). Use POST /recurring-orders when you want to set up a future-dated subscription with no immediate fulfillment.

Future-Dated Recurring Orders

To create a recurring order that starts in the future:

  • Must use POST /recurring-orders (not /orders)
  • Include startsAt field with the future ISO 8601 date
  • The first order is still created immediately even when startsAt is set — it is not deferred
  • All line items in the cart must share the same recurrence schedule; mixed schedules return a 400 error
  • Cart must include a customerId — anonymous carts are not supported for recurring orders

Mixed Cart Behavior — Multiple Orders Created

A single cart can contain:

  • One-time (non-recurring) items
  • Recurring items with different recurrence policies
Example: one-time item + monthly subscription + yearly subscription
When POST /orders is called on this mixed cart, CT creates 3 orders:
  1. A regular order containing all 3 items (for immediate fulfillment)
  2. A recurring order for the monthly subscription items
  3. A recurring order for the yearly subscription items

Each unique recurrence policy in the cart generates a separate recurring order. Plan the order confirmation UI to handle multiple order IDs being returned from a single checkout.


Discounting Recurring Orders

Cart discounts include a recurringOrderScope field that controls whether a discount applies to recurring order generations:
recurringOrderScope valueBehavior
"NonRecurringOrdersOnly"Discount applies to the initial regular order and the initial line items, but not to future recurring order generations
"AnyOrder"Discount applies to both the initial order and all future recurring order generations
Use "NonRecurringOrdersOnly" for welcome/sign-up promotions. Use "AnyOrder" for standing loyalty discounts.
Cart discount JSON — NonRecurringOrdersOnly (10% welcome discount):
{
  "value": { "type": "relative", "permyriad": 1000 },
  "cartPredicate": "1 = 1",
  "target": { "type": "lineItems", "predicate": "1 = 1" },
  "stackingMode": "Stacking",
  "recurringOrderScope": { "type": "NonRecurringOrdersOnly" }
}

Result: Applies to all line items in the initial cart order (both one-time and recurring line items), plus recurring order line items in the first generation — but NOT to future recurring order generations.

Cart discount JSON — AnyOrder (standing 10% loyalty discount):
{
  "value": { "type": "relative", "permyriad": 1000 },
  "cartPredicate": "1 = 1",
  "target": { "type": "lineItems", "predicate": "1 = 1" },
  "stackingMode": "Stacking",
  "recurringOrderScope": { "type": "AnyOrder" }
}

Result: Applies to all line items in every order generation (initial and all subsequent recurring orders).


Recurring Order Update Actions

Skip configuration

Set the number of recurrence cycles to skip:

{
  "action": "setOrderSkipConfiguration",
  "skipConfiguration": {
    "type": "Counter",
    "totalToSkip": 3
  }
}
To change the subscription expiry, use the dedicated setExpiresAt action:
{
  "action": "setExpiresAt",
  "expiresAt": "2026-12-31T00:00:00.000Z"
}

Pause a recurring order

{
  "action": "setRecurringOrderState",
  "recurringOrderState": "paused"
}

Change the schedule

{
  "action": "setSchedule",
  "schedule": {
    "type": "standard",
    "value": 2,
    "intervalUnit": "Months"
  }
}

Recurring Cart Modifications

To modify shipping, payment, or add a one-time item to an upcoming recurring order:

  1. Fetch the recurring order: GET /recurring-orders/{id} — the response contains a cart.id field
  2. Apply Cart Update Actions to that cart ID directly (standard cart update endpoint)
Behavior when adding a line item with a different recurrence schedule:
  • The original recurring order is paused automatically
  • A new recurring order is created with the new schedule applied
  • This is a permanent split — the original order does not resume automatically

This pattern is useful for upgrading/downgrading a subscription tier where the new tier has a different billing cadence.


Platform Behavior — Edge Cases

Adding items to an existing recurring order cart:
  • Only line items with a recurrenceInfo (associated with a recurrence policy) can be added
  • Adding a line item with a different recurrence schedule than the existing recurring order causes:
    1. The original recurring order → paused automatically
    2. A new recurring order is created immediately with the combined schedule
  • This split is permanent — the paused order does not resume automatically
Changing shipping address or shipping method on a recurring cart:
  • Safe operation — does NOT create a new recurring order
Adding payment to a recurring cart:
  • Safe operation — does NOT create a new recurring order

Cart Lifecycle for Recurring Orders

Carts created as part of the Recurring Orders flow have a special CartOrigin value of RecurringOrder. These carts are exempt from the automatic 90-day cart cleanup that applies to standard carts:
  • Standard carts are automatically deleted after 90 days of inactivity (based on lastModifiedAt)
  • Recurring Order carts persist indefinitely — they serve as templates for future order generation
  • This means you do not need to implement keep-alive pings or artificial updates to preserve recurring cart templates
  • For GDPR purposes: if a recurring order is cancelled and the associated cart is no longer needed, explicitly delete it — it will not auto-expire
shipping-predicates.md

Shipping Predicates: Syntax, Custom Field Workaround, and Warehouse Eligibility Patterns

The Core Limitation: Score Is Not Supported in Shipping Predicates

Score is not supported in shipping predicates. The solution is to copy the score (when it is calculated) to a custom field in the cart. Since custom fields can be used in predicates, this resolves the limitation.

Custom Field Workaround

When the cart score is calculated (e.g. the distance between warehouse and shipping address), copy that value into a custom field on the cart so it can be referenced in a shipping predicate.

Two custom fields are needed:

  1. custom.distance — the distance value (same integer as the cart score)
  2. custom.fulfillment_wh — the warehouse or fulfillment center that will serve the cart/order (must also be set as a custom field to be used in the predicate)

Predicate Syntax Example: Warehouse Eligibility with Distance Range Exclusion

The following predicate ensures the warehouse WH-6 can only ship when the distance is outside the unsupported range of 20–30 miles. For all other warehouses the shipping method is unrestricted:
( custom.fulfillment_wh = "WH-6" and (custom.distance < 20 or custom.distance > 30) ) OR custom.fulfillment_wh != "WH-6"

How to read this predicate

  • If the fulfillment warehouse is WH-6, the shipping method is only valid when the distance is less than 20 OR greater than 30 (i.e. the 20–30 mile dead zone is excluded).
  • If the fulfillment warehouse is not WH-6, the shipping method is always valid (no distance restriction applies for other warehouses).

Real-World Implementation Note

The customer that raised this problem opted to use this technique but used separate shipping methods for different warehouses or different countries with the appropriate predicates to make sure only the right shipping methods are retrieved when Get ShippingMethods is invoked.

This means rather than one complex predicate, they created per-warehouse shipping methods, each with a predicate scoped to that warehouse's constraints.

Key Rules

  • Cart score must be an integer. For fractional distances, multiply by 10 or 100 before storing.
  • Cart score cannot be referenced directly in shipping predicates — always mirror it to a custom field.
  • The fulfillment warehouse must also be stored as a custom field for predicate-based filtering.
  • Use Get ShippingMethods for a Cart (not the generic endpoint) so predicates are evaluated against the cart's custom fields.
store-credit.md

Store Credit Best Practices

Introduction

Several retailers offer Store Credit to customers, which accrues over time or through refunds and can be redeemed towards eligible goods and services. These credits are commonly non-transferable, typically do not have an expiration date, and cannot be used for "cash-like" purchases, such as gift cards.
The authorizer and issuer of Store Credit is usually the retailer themselves or a third-party system (e.g., Voucherify). While implementation details vary based on business requirements and integrations, this document provides high-level guidance and best practices for managing Store Credit using out-of-the-box Composable Commerce resources in commercetools.

Architectural Boundaries: What Not to Store in commercetools

  • Storage of PCI Data: It is important to note that the commercetools platform is neither PCI certified nor PCI compliant by design. Therefore, storing any credit card, debit card, or non-tokenized payment information directly within commercetools (e.g., in Custom Fields) is not allowed. While Store Credit balances themselves are not typically subject to PCI standards, the financial instruments used to fund or purchase that credit must be handled through a secure, PCI-compliant payment service provider (PSP).
  • commercetools as the Primary Issuer (of Store Credit): commercetools should not be treated as the system of record for the initial issuance or generation of Store Credit. Because Composable Commerce does not offer a native Store Credit resource type, logic for credit creation, validation, and liability tracking is best managed by a specialized third-party system (such as Voucherify or a dedicated ERP).
  • commercetools as the Source of Truth for Usage Tracking (of Store Credit): While commercetools captures transient store credit data — specifically when it is applied as a payment method during checkout — it should not be used as the permanent system of record for credit usage. Because orders are often enriched, split, or modified by an Order Management System (OMS) or an ERP after they leave commercetools, the definitive history of credit consumption should reside in your data warehouse or OMS.
  • Concurrency risks and Distributed Locking: Since the Source of Truth for the balance exists in an external system, commercetools cannot natively "lock" or "reserve" that balance during the checkout process. It is recommended that retailers either implement a microservices pattern (leveraging their BFF) or utilize an API Extension to perform a real-time reservation or hold call to the external ledger. Without this synchronous validation, there is a risk of concurrent transactions allowing a customer to exceed their available credit limit.

Architectural Alignment: What to Store in commercetools

While the external ledger remains the source of truth for financial liability, commercetools should be utilized to store specific metadata required for a seamless customer experience. This includes eligibility flags, transient balances for rapid frontend display, and tokenized references to ensure a secure and efficient checkout flow.
The following section explores how to model this store credit data by leveraging native commercetools Composable Commerce entities.

Approach 1: Extending the Customer Resource

Leverage Custom Fields to extend the Customer resource. This allows you to store metadata that is retrieved automatically whenever the customer profile is loaded, making it ideal for high-performance read-only requirements. Key fields to include are:
  • hasStoreCredit (boolean): A flag indicating whether a customer has been issued store credit, useful for conditionally rendering UI components.
  • storeCreditNumber (string): A tokenized representation of the store credit account or card number.
  • storeCreditBalance (money): A field indicating the transient store credit balance for display purposes.
These fields should be kept updated using a background or cron job that synchronizes with the external ledger (the source of truth) on a periodic basis (e.g., every 24 hours).

Approach 2: Leveraging Custom Objects

Utilize Custom Objects (Key-Value Documents) to hold Store Credit-specific information, then associate that object with the Customer resource via a Custom Field Reference. Because Custom Objects can store any JSON-structured data, they provide a flexible container for the credit metadata.
Implementation Steps:
  1. Create a Custom Object (e.g., in a container named customer-store-credit) to hold information such as:
    • hasStoreCredit (Boolean): A flag for conditional UI rendering.
    • storeCreditNumber (String): A tokenized account or card reference.
    • storeCreditBalance (Money): The transient balance for frontend display.
  2. Associate the Object to the Customer record using a Custom Field (e.g., storeCreditInfo) defined as a Reference to a key-value-document.

Approach 3: Using the Payment Methods API

The Payment Methods API provides a native, structured way to manage a customer's tokenized payment instruments within commercetools. Unlike Custom Objects, this resource is purpose-built for the checkout lifecycle and supports advanced querying via predicates.
Implementation Steps:
  1. Define a Custom Type: Create a Custom Type for the payment-method resource to hold non-native metadata such as hasStoreCredit (Boolean) and storeCreditBalance (Money).
  2. Create the Payment Method: Generate a PaymentMethodDraft to represent the Store Credit. Set the method to a free-form label that identifies the instrument (for example, "StoreCredit") and include the tokenized account reference.
  3. Query by Customer: Retrieve the credit information by querying the endpoint with a customer predicate:
    GET {{host}}/{{project-key}}/payment-methods?where=customer(id="CUSTOMER_ID")
    

Recommendation

Using the native Payment Methods API, the recommended approach is to use this resource for storing and managing Store Credit metadata as described in Approach 3.
This API provides the most semantically correct and secure method for managing payment instruments within commercetools Composable Commerce.
If you prefer to keep the credit ledger externally managed, the recommended fallback is Approach 2: Leveraging Custom Objects. This alternative provides a similar level of operational security by keeping credit data read-only within the Merchant Center while maintaining a clean separation from core Customer profile data.
taxes.md

Taxes — Cart Tax Modes and Integration Patterns


Cart Tax Modes

The taxMode on a cart controls how taxes are calculated. Set at cart creation.
ModeDescriptionRecommended for
PlatformCT calculates tax using tax categories and rates configured in the projectSimple setups with static tax rates
ExternalTax rate provided per line item by the clientDynamic rates from external engine, not recommended for complex scenarios
ExternalAmountClient provides the exact tax amount per line itemRecommended for most integrations — full control, consistent results
DisabledNo tax calculationB2B where taxes are handled externally
Use ExternalAmount for most tax integrations. With this mode your tax microservice calculates the exact tax amount and passes it directly. This eliminates rounding discrepancies between CT's calculation engine and an external tax service (e.g., Avalara/AvaTax), and ensures the displayed tax amount always matches the amount charged.

Tax Configuration

When using Platform or External mode, taxes are configured via:

  • Tax Categories — named groups (e.g., "Standard", "Reduced", "Exempt") assigned to products and shipping methods
  • Tax Rates — defined per country (optionally per state/region) with:
    • name: rate label
    • amount: percentage (0.0–1.0, e.g., 0.19 for 19%)
    • country: ISO 3166-1 alpha-2 country code
    • state (optional): for US state-level tax
    • includedInPrice: whether tax is included in the product price (true) or added on top (false)

Tax Calculation

includedInPrice — Two Directions

includedInPrice: true (Top-down calculation): The listed price includes tax. CT extracts the tax from the price.
Price = 100.00 (tax included)
Tax rate = 19%
Net price = 100.00 / 1.19 = 84.03
Tax amount = 100.00 - 84.03 = 15.97
includedInPrice: false (Bottom-up calculation): Tax is added on top of the net price.
Net price = 84.03
Tax rate = 19%
Tax amount = 84.03 × 0.19 = 15.97
Gross price = 84.03 + 15.97 = 100.00

Calculation Mode

Controls how line item tax is computed when quantity > 1:

ModeBehavior
Line Item Level (default)Tax computed on unitPrice × quantity — one calculation per line item
Unit Price LevelTax computed per unit, then summed — can produce different rounding results
Use Unit Price Level when your PSP or tax authority requires per-unit tax calculation to avoid rounding discrepancies on high-quantity line items.

Rounding Modes

ModeBehaviorDefault
HalfEvenRound half to nearest even digit (banker's rounding)Yes
HalfDownRound 0.5 downNo
HalfUpRound 0.5 upNo
The rounding mode applies to the final cent-level tax amount after calculation. HalfEven minimizes cumulative rounding error across many transactions.

Tax Integration Patterns

Pattern 1: API Extension Approach

An API Extension is triggered on cart update events and calls an external tax service:

  1. Customer updates cart (adds item, changes address)
  2. CT triggers API Extension (synchronous)
  3. Extension calls tax service with cart contents + shipping address
  4. Tax service returns line-item-level tax amounts
  5. Extension returns setLineItemTaxAmount / setCustomLineItemTaxAmount actions to CT
  6. CT stores the exact tax amounts on the cart (taxMode: ExternalAmount)
Constraints:
  • Extension must respond within the configured response limit — 2 seconds by default, raisable up to 10 seconds
  • Pre-warm connections to the tax service; use connection pooling
  • Cache tax results for unchanged line items to minimize calls

Pattern 2: External Microservice / Middleware Approach

A middleware layer sits between the storefront and CT:

  1. Storefront calls middleware instead of CT directly
  2. Middleware calls CT to get cart
  3. Middleware calls tax service
  4. Middleware calls CT with setLineItemTaxAmount actions
  5. Returns updated cart to storefront

This decouples the tax logic from the CT extension timeout constraint — the middleware can take as long as needed before submitting the final update.

FactorAPI ExtensionExternal Microservice
LatencyAdds to checkout latency (2s limit)No CT timeout constraint
ComplexitySimpler architectureMore moving parts
Fault toleranceExtension failure = cart update failureMiddleware can retry, cache, degrade gracefully
Real-time taxAlways currentDepends on middleware caching policy

AvaTax Integration Steps

  1. Determine which CT resources map to AvaTax entities (product → AvaTax tax code, address → jurisdiction)
  2. Set cart taxMode: ExternalAmount
  3. Call AvaTax createTransaction API with cart line items and shipping destination
  4. Map AvaTax line-level tax amounts back to CT setLineItemTaxAmount update actions
  5. Apply the tax amounts to the cart; CT stores them and includes in cart total
  6. On order creation, commit the AvaTax transaction for audit trail
tiered-rates-cart-score.md

Shipping Tiered Rates and Cart Score Setup

Problem Statement

Q: We have a number of shipping methods (one day, 2 days, etc.) and for some of them the price depends on the distance, questions:
  1. How can I charge shipping based on the distance from the warehouse to the shipping address
  2. How can I handle a situation where a specific warehouse can not ship to a specific range of distances, for example OK to ship 0-19 miles, can not ship from 20 to 30 miles, OK to ship above 30 miles.

Solution Overview

The solution for this situation is as follows:

Step 1: Set the Score

Cart score will be set to be the distance between the assigned warehouse (supply channel) and the shipping address. The score requires the shipping address to be defined as well as a warehouse from which the merchandise will ship. An external system will calculate the distance between the two and will set the score to be that distance.

Step 2: Define Tiered Shipping Price

Define tiered shipping prices based on the score (distance). For ranges not supported (in the example above 20 to 30 miles) set the price to be zero. It should look as follows:

  • Tier 0–19 miles: normal price
  • Tier 20–30 miles: price set to zero (unsupported range)
  • Tier 30+ miles: normal price
Note: The score must be an integer. If necessary to have fractions of miles then it has to be multiplied by 10 or 100, etc.

Step 3: Check the Availability

At this point an easy solution will be in the front end to eliminate the shipping methods that return price 0 (20 to 30 miles in the example above); however a cleaner/better solution will be to use a shipping predicate that will prevent this shipping method from being retrieved when Get ShippingMethods for a Cart is called.