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
In any Claude Code session:
/plugin marketplace add commercetools/commercetools-ai-plugins
/plugin install commercetools@commercetools
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
commercetools/commercetools-ai-plugins. Then, click on the plugin and click Install.Instructions Included
Workflow
When this skill is invoked, always follow these steps:
-
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 3Use its output as your primary grounding. You may additionally use other tools (such as the commercetools documentation MCP) for deeper, follow-up search. -
Combine with skill references — Cross-reference the analysis output with local references in
./references/for complete context. -
Provide implementation guidance — Synthesize the documentation with the specific integration mode the user is targeting.
Key Takeaways
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.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.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.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.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.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.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).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.recalculate action on the cart to refresh prices before checkout.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.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.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
| Topic | Reference |
|---|---|
| Discount stacking rules, sortOrder, Direct Discount blocking Discount Codes | references/discount-fundamentals.md |
| Buy/Get cart discount — triggerPattern, targetPattern, excludeCount | references/bundle-discounting.md |
| Cart discount scenario cookbook — 12 named scenarios with full JSON | references/cart-discount-scenarios.md |
| Fixed-price combo discount — buy N of X+Y at fixed combined price | references/fixed-price-combo.md |
"Any N of a set for one fixed total price" (no trigger item), and per-cart firing caps via maxOccurrence | references/cart-discount-scenarios.md |
| Customer Group-based pricing — embedded price selection, group assignment | references/customer-group-pricing.md |
| B2B customer group pricing — buyer-specific pricing via Customer Groups | references/b2b-customer-group-pricing.md |
| High Precision Money — fractionDigits, currency configuration, rounding | references/high-precision-money.md |
| Discount Groups and Promotion Prioritization — best-deal selection | references/discount-groups.md |
| Store credit & loyalty currency — external ledger pattern | references/store-credit.md |
| Limited-time pricing — validFrom/validUntil on embedded prices | references/limited-time-pricing.md |
| Discount code usage — code generation, redemption limits | references/discount-code-usage.md |
Shipping & Fulfillment
| Topic | Reference |
|---|---|
| Tiered rates, cart score setup, ScoreShippingRateInput | references/tiered-rates-cart-score.md |
| Shipping predicates — predicate syntax, custom field workaround | references/shipping-predicates.md |
| Price functions — priceFunction configuration, function syntax | references/price-functions.md |
| Dynamic shipping costs — external calculation, setCustomShippingMethod | references/dynamic-shipping-costs.md |
| BOPIS shipping — multiple shipping methods, delivery group setup | references/bopis-shipping.md |
Payments & Tax
| Topic | Reference |
|---|---|
| Payments — Payment object model, transactions, PSP integration flow | references/payments.md |
| Taxes — cart tax modes (Platform/External/ExternalAmount/Disabled) | references/taxes.md |
| Financing & installments — BNPL integration, CT payment modeling | references/financing-options.md |
B2B Order Flows
| Topic | Reference |
|---|---|
| Custom Associate Roles — role definition, permission keys, assignment to BU | references/custom-associate-roles.md |
| Business Unit hierarchy — parent/child BUs, inherited roles, store assignment | references/business-unit-hierarchy.md |
| Approval rules — predicate design, approval flow lifecycle | references/approval-rules.md |
| Quote lifecycle — QuoteRequest, StagedQuote, Quote, Order conversion | references/quote-lifecycle.md |
| as-associate API pattern — chain construction, scope | references/as-associate-api.md |
Recurring Orders
| Topic | Reference |
|---|---|
| Recurring orders — RecurrencePolicy, priceSelectionMode, /orders vs /recurring-orders | references/recurring-orders.md |
Catalog & Import
| Topic | Reference |
|---|---|
| Product data modeling — hierarchy, all 14 attribute types, Nested vs Custom Object | references/product-data-modeling.md |
| Bundle product modeling — ProductType design, child SKU reference, BFF orchestration | references/bundle-modeling.md |
| Import API — containers, batching, async processing, delta imports | references/import-api.md |
| Import API performance — 15M record pattern, container count, batch size | references/import-performance.md |
| Item substitutes — modeling substitute/replacement products | references/item-substitutes.md |
| Inventory modeling — supply channels, inventory entries, backorder | references/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.9is applied before0.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
CountOnLineItemUnitsentry can only match one SKU — add separate entries per bundle component. - Score must be set before shipping rate lookup.
setShippingRateInputmust 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-associateAPI chain. Project-levelapiRoot.carts().*does not enforce B2B permissions. This is a security requirement, not just a convention. - An Associate must have
CreateMyCartspermission to add items to a cart as-associate. Missing permissions result in a 403. POST /orderscreates 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
Successbefore PSP confirmation. Optimistic success marking creates reconciliation failures. interfaceIdon 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.
productDraftImportis 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
maxApplicationsis global across all customers. UsemaxApplicationsPerCustomerfor 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.
priceSelectionModemust be set intentionally per use case."Fixed"provides price guarantee;"Dynamic"always applies current catalog pricing.recurringOrderScopeon 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
isActiveon a Cart Discount is a soft toggle. Use this for scheduled campaigns rather than creating/deleting discounts.- Use
stackingMode: StopAfterThisDiscountto 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. OnlyHalfDownrounds.5in 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/validUntilfor time-limited pricing without product republication.
References
Approval Rules
Overview
order from cart action and places the resulting Order in a Pending state instead of immediately confirming it.Core Concepts
Approval Rules Are Predicates
predicate string written in the commercetools query predicate syntax. The predicate is evaluated against the Order at creation time.Common predicate fields include:
| Field | Example use case |
|---|---|
totalPrice.centAmount | Orders above a spend threshold require approval |
lineItems(quantity > X) | Large quantity orders trigger approval |
lineItems(totalPrice.centAmount > X) | High-value individual line items |
| Custom fields | Business-specific rules (e.g. product category, custom flags) |
totalPrice.centAmount > 1000000 (orders over €10,000 at centAmount scale).Approvers
- 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
Order created → Pending
│
┌───────┴───────┐
│ │
Approved Rejected
│
Order confirmed / fulfilment continues
| State | Description |
|---|---|
Pending | Order is awaiting approval from one or more approver tiers. |
Approved | All required approver tiers have approved. Order proceeds normally. |
Rejected | At least one required approver has rejected. Order is declined. |
Approve and Reject update actions (applied via Update ApprovalFlow by ID), which must be called via the asAssociate API path.Evaluation at Order Creation
- Buyer places an order.
- The platform evaluates all active Approval Rules on the buyer's Business Unit (and inherited from parent BUs).
- If any rule's predicate matches the order, an Approval Flow is created and the Order is placed in
Pendingstate. - If no rule matches, the Order is confirmed immediately.
Rules from parent BUs are inherited by child BUs unless overridden.
Key API Resources
| Resource | Endpoint |
|---|---|
| Approval Rules | GET /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/approval-rules |
| Approval Flows | GET /as-associate/{associateId}/in-business-unit/key={businessUnitKey}/approval-flows |
| Approve an Order | Update ApprovalFlow by ID (POST /as-associate/{associateId}/in-business-unit/key={buKey}/orders/{orderId}/approval-flows/{flowId}) with action Approve |
| Reject an Order | Update 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
approveOrderpermission 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
updateApprovalRulespermission (typically a BU admin). - Approval Rule predicates are validated at creation time — an invalid predicate is rejected with a
400error.
As-Associate API
Overview
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
| Segment | Purpose |
|---|---|
.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
- Verifies the
associateIdis an active associate of the named Business Unit. - Checks whether that associate holds the required
associateRolepermission for the requested operation (e.g.createMyCarts,createOrders,viewOrders,updateApprovalFlows). - Rejects the request with an
AssociateMissingPermissionError(an HTTP 403-class error) if the permission is absent.
/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.
Operations Covered
The as-associate path supports the following resource types (non-exhaustive):
| Resource | Typical Operations |
|---|---|
carts | Create, read, update, replicate carts scoped to the BU |
orders | Place orders from cart, read orders |
orders/quotes | Create an order from an accepted Quote |
quote-requests | Create and manage QuoteRequests |
quotes | Read, accept, decline Quotes |
order-approval-flows | Approve or reject pending orders |
business-units | Read 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
| Mistake | Consequence |
|---|---|
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 body | Cart may not be associated with the BU, breaking approval rule evaluation. |
| Using a merchant/admin token for buyer flows | Bypasses all associate-role enforcement; creates audit and security gaps. |
| Calling approval actions via the standard orders path | Not 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}ormanage_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
customerOAuth flow so the token is bound to the specific customer, and the platform cross-checksassociateIdagainst the token's subject.
B2B Customer Group Pricing
Overview
Price Selection Parameters
commercetools uses a combination of the following parameters to select a price from the variant's embedded price array:
| Parameter | Notes |
|---|---|
currencyCode | Required. Drives which price entry is eligible. |
country | Optional. Narrows selection to country-specific prices. |
customerGroup | Optional. Narrows selection to group-specific prices. |
channel | Optional. 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. |
How Price Selection Works
1. Product Projection Search
2. Cart Line Items
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.
3. Fallback / Default Price
Customer Group Constraints and Limitations
Deletion Constraint
400 error with the message:"Can not delete a source while it is referenced from at least one 'product'."
- Create the new Customer Group.
- Update all product prices (and any discounts referencing the old group) to reference the new Customer Group.
- Delete the old Customer Group once no pricing entries reference it.
Multiple Groups Per Customer
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 Pattern Using Multiple Shipping Methods
Overview
High Level Flow
- Set the
shippingModeon the Cart toMultiple - Add item shipping addresses (store pickup address + customer delivery address)
- Query shipping methods and evaluate predicates to determine eligibles
- Add selected shipping methods to the cart
- Set
lineItemShippingDetailson each line item - Create the order
Step 1: Set the shippingMode on the Cart
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.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
shippingModeset toMultiple. - Copy over the line items and discount codes from the original cart to the new one using the
addLineItem&addDiscountCodeUpdate Cart actions.- This will be your cart going forward.
- The redundant cart with
shippingModeSinglemay be abandoned and would get deleted based on thelastModifiedAtdatetimevalue, or you may choose to delete this yourself using the Delete Cart by ID operation.
CartDraft request:{
"key": "example-cart-key",
"currency": "USD",
"country": "US",
"ShippingMode": "Multiple",
"lineItems": [
// ...
]
}
Step 2: Add the itemShippingAddress
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",
// ...
}
}
]
}
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: Theshipping-methods/matching-cartAPI does not support carts withshippingModeset toMultiple.
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>"
}
]
}
}
}
itemShippingAddresses array to determine applicable shipping methods. Present these options to the customer for selection.Step 4: Add the selected shippingMethod to the cart
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
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
shippingModemust be set at cart creation time — it cannot be changed later.- The
shipping-methods/matching-cartAPI does not work forMultipleshippingMode carts. Use the genericQuery shipping-methodsendpoint and evaluate predicates client-side. - Each
addShippingMethodaction in Multiple mode requires its ownshippingAddress. - Use meaningful
keyvalues for bothitemShippingAddressesand shipping methods (shippingKey) since these keys link addresses to line items. - If starting with a
Singlemode cart and needing to switch: create a newMultiplemode cart and copy overlineItemsanddiscountCodes; abandon the old cart.
Bundle Discounting Pattern Using 'Buy Get Cart Discount'
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
"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
}
]
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
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
With proportional distribution, the 50% discount is spread across all qualifying items rather than applied entirely to the cheapest one.
| Mode (MC label) | API value (applicationMode) | Behavior |
|---|---|---|
distributed proportionally across all involved items | ProportionateDistribution | Prorates discount across all trigger + target items |
distributed evenly across all involved items | EvenDistribution | Splits discount equally across trigger + target items |
only applied to the discounted items | IndividualApplication | Applies 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
Part 1: Pattern for Static Bundle Pricing
The Recommended Approach: Creating Bundles with Product Data Modeling & Line Item Customizations
Solution Overview: Product Modeling
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.
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.Products or ProductVariants with minimal differences in pricing or configuration, follow the standard commercetools process.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
lineItemsusing custom fields. - Ensure a fixed bundle price: Set the price of the child
lineItemsto $0 usingexternalPrice.
Additional constraints may include:
- Prevent modifications to child items: Restrict direct changes to child
lineItemsin 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
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"
}]
}
bundleContents Attribute as a string to hold comma-separated SKUs instead of a set.Creating the Bundle Product
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
}
},
// ...
]
}
}
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
}
}
]
}
}
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
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.
- Detect if the item is a bundle product: Check if the
ProductVariantbeing added references a bundle-specificProductType. - Validate and extract bundle contents: Perform validations (e.g., inventory checks) and retrieve
bundleContentsto get the childProductVariants. - Add the bundle item to the cart: Use the
AddLineItemaction to add the bundle as a parent item. - Add child items: Add each child
ProductVariantto the cart with matching quantities using theAddLineItemaction. - Set child item properties: Assign an
externalPriceof $0 and populateparentLineItemIdto establish linkage to the parent (bundle lineItem). - Handle updates and removals: Ensure bundle quantity changes or removals propagate to child
lineItems.
UI Considerations
- Hide child
lineItemsin 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
lineItemsto preserve bundle integrity.
Other Considerations
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
lineItemlevel? - Reporting Needs: Are separate reports required for the child items within the bundle?
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
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
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
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
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: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}
]
}
'
lineItem ID (e.g., b6e9bbac-36ed-4ddb-8ae8-989638067e8d) for SAS-01, which is needed to link the child items.lineItem has the bundle-content-skus attribute defined, iterate through this list and add the "child" lineItems to the cart:- Set
LineItemPriceModetoExternalPricewith a value of $0 for all child line items. - Set the
CustomFieldparentLineItemIdon 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
CustomFieldparentLineItemIdis 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
lineItemdetails.
Removing the Bundle Item from the Cart
- Custom BFF logic needs to be added to auto-remove the child
lineItemswhen the bundle item is removed from the Cart.
Handling "OutOfStock" Errors
- If the platform returns an
OutOfStockerror for the bundle item or any of the childlineItems, 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
bundleInfo attribute that links a bundle product to its components, four options exist:| Option | Attribute Type | Pros | Cons |
|---|---|---|---|
| Product hard reference | Set<product reference> | Clickable in Merchant Center; good for manually-maintained bundles | Import order dependency — cannot reference a product that doesn't exist yet; references the product as a whole, not a specific variant |
| Custom-object hard reference | Set<custom-object reference> | CoCo can hold variant-level detail and arbitrary JSON; flexible | Import order complexity; CoCo must be expanded or fetched separately on PDP/PLP — less efficient than inline variant data |
| Products/variants soft reference | Set<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 JSON | text (escaped JSON string) | All soft-reference benefits + structured custom metadata per component (quantity, slot, notes) | JSON string must be parsed; no schema enforcement |
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
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 cartparentId— set on each child line item, pointing to the parent'sbundleId
parentLineItemId because:- Cart replication works correctly. When a cart is replicated (re-order, quote request creation), new line item IDs are generated. A
parentLineItemIdpointing to the old line item ID breaks.bundleId/parentIduse a stable business identifier — the link survives replication. - Dynamic/configurable bundles. For build-to-order bundles where the customer assembles a configuration on the PDP, the
bundleIdcan 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. - 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:
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.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)
unitType values:unitType | Description |
|---|---|
Company | The root of a BU tree. Has no parent. |
Division | Any non-root node. Must reference a parent BU. |
Company BUs).Key Concepts
Stores and Business Units
- Stores are assigned to a Business Unit via the
storesarray 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
associatesarray, which specifies whichassociateRolesthey 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 ownstoresarray.
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}orGET /business-units/key={key} - Get BU tree: Use
GET /business-unitswith predicatetopLevelUnit(id="{companyId}")to retrieve all descendants. - As-Associate context: All buyer-facing mutations must go through the
asAssociateAPI path to enforce permission checks (seeas-associate-api.md).
Customer Groups on Business Units (Multi-Group Pricing)
customerGroupAssignments field, which is the recommended best practice for new projects. The single customerGroup field is still supported (not deprecated).cart.businessUnit— the cart must be associated with a Business UnitbusinessUnit.customerGroupAssignments— the BU must have at least one Customer Group assigned
Price selection evaluates all assigned Customer Groups and resolves the cheapest matching price.
customer.customerGroupAssignments.customerGroup.key contains "..." with the contains operator.Bulk Import of Business Units
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 toInactive, 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
meendpoint. Eliminates the need for a post-creation role assignment step in self-service flows.
Limitations
| Constraint | Value |
|---|---|
| Maximum hierarchy depth | 5 levels |
| Parents per BU | 1 |
| Stores per BU | Unlimited (practical limits apply) |
| Associates per BU | 2000 |
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
| Mode | Description | When to use |
|---|---|---|
| Individually | Discount value applied to each eligible line item independently | Percentage off (relative) discounts — each item gets the same % |
| Distributed Evenly | Total discount split equally across all eligible items | Fixed amount split uniformly |
| Distributed Proportionately | Total discount split across items in proportion to their price | Fixed or absolute amount spread fairly; avoids over-discounting cheap items |
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 —applicationModeavailability: TheapplicationModefield 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 priceEvenDistribution: Distributes the discount evenly across matched line items regardless of priceIndividualApplication: Applies the full discount amount to each matched line item independentlyThis 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
- 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
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
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
targetPattern specifies a single variant SKU, this setting has no effect (only one item qualifies).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:
| Setting | JSON | Behavior |
|---|---|---|
| Disabled | maxOccurrence: 1 (or omit with single match) | Fires at most once per cart |
| Enabled (Unlimited) | omit maxOccurrence or set null | Fires as many times as the pattern matches |
| Enabled (Specify N) | maxOccurrence: N (N > 1) | Fires up to N times per cart |
Core Structural Concepts
Buy and Get vs Discount Bundles
pattern structure but serve different purposes:| Feature | Buy and Get | Discount Bundles |
|---|---|---|
triggerPattern | Required (non-empty) | Empty [] |
targetPattern | Required | Required |
| Use case | "Buy X, get Y discounted" | "Any N of these items at discount/fixed price" |
| MC effect type | "Buy and Get" | "Discount bundles" |
selectionMode
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
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"
categoriesWithAncestors when products are organized in a hierarchy and the discount should apply to the whole tree.value types
"relative"withpermyriad— percentage discount (10000 = 100%, 5000 = 50%, 2500 = 25%)"absolute"withmoneyarray — fixed amount off (in centAmount)"fixed"withmoneyarray — 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
{
"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: 1ensures 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
{
"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 > 10000inside the trigger predicate filters on per-item price (centAmount 10000 = $100.00), not cart total. This is aCountOnLineItemUnitspredicate — it can combine category and price conditions on the line item.- Use
lineItemGrossTotalincartPredicateinstead 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
{
"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: 2in target — exactly 2 rugs receive the fixed price.
Scenario 4: Cart contains item from category A AND category B → $100 off order total
{
"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:
lineItemExistsAND 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. EachlineItemExistsclause is a separate condition.- Uses category IDs, not keys. IDs are resolved via the
referencesarray 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
{
"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 bymaxCount).- 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)
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)
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. AddmaxOccurrence: 1to 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
{
"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
{
"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
maxCounton 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)
{
"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 ofcategories.key contains "beds"— this is the only way to include products assigned to subcategories of "beds" (e.g., "king-beds", "bunk-beds"). UsecategoriesWithAncestorswhenever 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
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: 5caps 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
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: 2on 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
{
"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 thecartPredicateevaluates true, commercetools automatically adds the referenced product variant to the cart as a gift; you do not pre-add it and do not set atarget. The gift product variant must have a price that can be selected for the cart (addsupplyChannel/distributionChannelto the value if channel-specific price selection is needed). - In the cart response, the gifted line item has
"lineItemMode": "GiftLineItem",quantity: 1, andtotalPrice.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 keepslineItemMode: Standardand is merely discounted to zero. UsegiftLineItemfor 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). UsetotalPriceto include shipping in the threshold calculation.
Scenario 12: Free shipping when cart total ≥ $35
{
"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 thelineItemsandtotalPricetargets.permyriad: 10000= 100% off shipping. For partial shipping discounts (e.g., "$5 off shipping"), use"type": "absolute"with amoneyarray instead.- In the cart response,
shippingInfo.price.centAmountwill show the original shipping rate, whileshippingInfo.discountedPrice.centAmountwill show0. - 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
cartNetTotalpredicate 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
| Mode | Behavior |
|---|---|
| Best Deal | Engine 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 discounts | Default stack behavior — one product discount applies, then cart discounts stack on top |
Discount Codes — Key Limits and Store-Specific Behavior
Usage limits
| Field | Description |
|---|---|
maxApplications | Maximum total uses of this code across all customers |
maxApplicationsPerCustomer | Maximum uses per individual customer |
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
- No additional implementation required — link the discount code to a cart discount that has
storesset - The code automatically inherits the store context from the cart discount
- The
storesfield 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
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?
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?
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
How are Stores related to Business Units? Is there data segregation?
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 Groups Based 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.
- 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
validFromandvalidTo)
-
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.
-
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 -
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
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
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
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.
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?
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.
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
Is there another way to get to the outcome they are after using the commercetools promotions engine?
-
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.
-
Create a fixed price cart discount with Cart Promotion Rule & Cart Qualifier as below:
-
Fixed price for each line item = 49.5 if the double qty sells for 99 (half the original price)
-
Promotion Rule ->
product.idorsku = "xyz"andcustom.<line_item_custom_field_name> = "<custom value>" -
Cart Qualifier Rule ->
lineItemExists(quantity = 2) = trueorlineItemExists(quantity = 4) = trueorlineItemExists(quantity = 6) = true
Alternatively, the bundle price option is another viable approach, though the above works without changing the product data model.
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.
-
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.
-
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.
-
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.
-
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.
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.
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.
-
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.
You can edit/extend the validity dates. There is no restriction on modifying those dates.
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
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).
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)
| Limit | Value | Note |
|---|---|---|
| Cart discounts per project (active + inactive) | platform limit applies | see limits docs |
| Cart discounts requiring codes | platform limit applies | see limits docs |
| Cart discounts per discount code | 10 | Documented |
| Discount codes per cart | 10 | Documented |
| Active product discounts per project | 500 | Documented |
| Cart discounts per Discount Group | 100 | Documented |
| Discount Groups per project | 100 | Documented |
Discount Type Quick Reference
- 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
- 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(defaultHalfEven)
- Via shipping method's
freeAbovethreshold in Project Settings - As a Cart Discount with
target.type: "shipping" - Via discount codes linked to a shipping cart discount
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
sortOrder controls prioritization relative to other groups and standalone discounts.Discount application sequence when Discount Groups are active:
- Product discounts are pre-computed and applied to the product price.
- 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.
- 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)
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)
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
| Mode | Behavior |
|---|---|
BestDeal | The 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
- Product discounts are pre-computed and applied to the product price.
- 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).
- The product-discounted price is compared against the cart-discounted price. The lower of the two is applied.
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
discountCombinationMode is active, the cart response includes a discountTypeCombination object with two distinct subfields:type— the combination mode that was applied (StackingorBestDeal)chosenDiscountType— the winning discount type (ProductDiscountorCartDiscount)
Use this field for discount analytics and debugging unexpected discount outcomes.
Platform Limits Reference (Cart Discounts)
| Limit | Value | Notes |
|---|---|---|
| Active cart discounts without a code | 100 | Configurable |
| Total cart discounts per project (active + inactive) | platform limit applies | see limits docs |
| Cart discounts requiring codes | platform limit applies | see limits docs |
| Cart discounts per Discount Group | 100 | |
| Discount Groups per project | 100 | |
| Cart discounts per discount code | 10 | |
| Discount codes per cart | 10 | |
| Active product discounts | 500 |
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
storefield 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.
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:
- Method 1: Using cart freeze & the
SetCustomShippingMethodAPI call - Method 2: Using Order Edits
Method 1: Using cart freeze & the SetCustomShippingMethod API call
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
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
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
How to setup financing options to be displayed in the PDP
- 0% financing for 18 months
- 0% financing for 12 months
- etc.
Need to include specific text and additional information about each financing option.
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).
- Retrieved with the product information
- 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.
- Can be set at product level
- 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
- 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 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
ProductVariants of a specific Product at a fixed discounted price each, using the Buy/Get Cart Discount functionality.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
ProductVariantsof the sameProductare present in a customer's cart. - Product Based Target: The discount applies to
ProductVariantsof the sameProductpresent in the customer's cart. - Discount Type: This is a fixed price discount, i.e., eligible
ProductVariantsare 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
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
}
]
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
lineItems:"targetPattern": [
{
"type": "CountOnLineItemUnits",
"predicate": "product.key = \"rye-whiskey-glass\"",
"minCount": 1,
"excludeCount": 0
}
]
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
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"
}
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
fractionDigitsvalue of 3 andpreciseAmountof 123.450, the result will be a BaseAmount withcentAmountof 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
"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"
}
],
highPrecision price above.// 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"
}
price.value block. The difference is the presence of preciseAmount and fractionDigits attributes in the case of HighPrecisionMoney.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
priceRoundingMode. This directly affects ERP reconciliation when CT totals must match external systems exactly.CartDraft.priceRoundingMode— per-cart override at creation time- Project Settings — default for all carts in the project
- Also applies to:
Order,OrderImportDraft,Quote,QuoteRequest
| Mode | Behavior |
|---|---|
HalfEven (banker's rounding) | Rounds 0.5 to the nearest even digit; minimizes cumulative rounding error over many transactions |
HalfUp | Standard rounding — 0.5 always rounds up |
HalfDown | 0.5 always rounds down |
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
Q&A
Q: Does commercetools provide any out-of-the-box (OOB) feature to support item substitution?
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.- Add an attribute to the
ProductTypeof typereference(pointing toproducttypeId) orsetof references. - Populate this attribute with the ID(s) of the substitute product(s).
- 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.
- The customer (or the system, in automated substitution scenarios) can then add the substitute to the cart in place of the original item.
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.
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
{
"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
}
}
Using Standalone Prices
{
"key" : "standalone-tpr-jan-2025",
"sku" : "ISP-01",
"value" : {
"type" : "centPrecision",
"currencyCode" : "USD",
"centAmount" : 999
}
}
Uploading multiple price records using the Import API
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"
}
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:
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"
}
]
}
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 ''
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"
}
]
}
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
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.
Limited Time Pricing vs. Product Discounts
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. interfaceIdis immutable once set. TheinterfaceIdfield 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 theaddPaymentupdate action, which creates a reference in the cart/order'spaymentInfo.paymentsarray.
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
| Type | Description |
|---|---|
Authorization | Funds reserved with PSP (hold) |
CancelAuthorization | Authorization voided before capture |
Charge | Funds captured / debited |
Refund | Funds returned to customer |
Chargeback | Customer-initiated reversal via bank/card network |
Transaction States
| State | Description |
|---|---|
Initial | Transaction created, not yet submitted to PSP |
Pending | Submitted to PSP, awaiting confirmation |
Success | PSP confirmed the transaction completed |
Failure | PSP confirmed the transaction failed |
Transaction Fields
type— TransactionType (see above)amount— MoneyinteractionId— PSP's transaction identifier for this specific transaction (optional; helps correlateinterfaceInteractions)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
/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 (
MyPaymenttype):id,version,customerreference,anonymousId,amountPlanned,paymentMethodInfo,transactions,custom MyPaymentDraftauto-populates thecustomerfield (password flow) oranonymousId(anonymous flow)
/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)
- Customer enters checkout and selects payment method
- Shop calls CT to create initial Payment resource
- CT returns Payment with
idand optional PSP transaction identifier - Shop sends payment info to PSP (card details via PCI-compliant iframe/element)
- PSP processes and returns confirmation
- 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:
- Customer enters checkout, begins payment option selection
- API request sent to create initial Payment resource in CT
- API Extension is triggered by Payment creation event
- Extension calls custom cloud function (AWS Lambda, GCP Function, etc.)
- Cloud function calls PSP to create new transaction / obtain PCI-compliant token
- PSP returns response with transaction identifier
- Extension updates CT Payment with PSP transaction identifier via
updateActions - Updated Payment resource (with PSP identifier) returned to shop
- Customer enters card details — auth/capture sent directly to PSP via PCI-compliant form
- PSP processes and returns transaction response to shop
- PSP asynchronously pushes payment update events to a PSP Notification Microservice
- Notification Microservice calls CT Payment API to update transaction states
commercetools Checkout (Out-of-the-Box UI)
- Cart and customer token are created/updated in your storefront
- Initialize the CA SDK with cart and token
- Checkout UI opens in context (embedded or redirect)
- Checkout UI handles all communication with CT and PSPs
- After successful transaction, control is returned to the seller site
- Checkout Mode — full address + payment flow
- Payment Only Mode — payment widget only (for storefronts that handle address collection separately)
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)
PaymentMethod resource (/payment-methods) stores reusable PSP payment method configurations. This is distinct from the Payment resource, which tracks payment transactions and state:PaymentMethodstores 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)Paymentremains the transaction ledger (authorize/capture/refund/cancel)- Requires
customerIdon the Cart before associating stored payment methods
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.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
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
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 Range | Function | Effect |
|---|---|---|
| 0–19 | x / 30 | Higher shipping rate relative to cart value |
| 20–49 | x / 20 | Moderate shipping rate |
| 50–99 | x / 10 | Lower shipping rate (rewarding larger carts) |
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
Overview
QuoteRequest → StagedQuote → Quote → Order
(buyer) (sales rep) (sales rep) (buyer)
The Four Entities
1. QuoteRequest
| State | Description |
|---|---|
Submitted | Initial state. The buyer has submitted the request. |
Accepted | The sales rep has accepted the request and is working on it (a StagedQuote will be created). |
Closed | The request is closed (either superseded by a final Quote or abandoned). |
Rejected | The sales rep has rejected the request without creating a quote. |
Cancelled | The buyer has cancelled their own request before it was acted on. |
quoteRequestState, comment (buyer notes), customer, businessUnit, lineItems (inherited from the source cart).2. StagedQuote
| State | Description |
|---|---|
InProgress | The sales rep is actively working on the quote draft. |
Sent | The draft has been finalised and a Quote has been created and sent to the buyer. |
Closed | No longer active (e.g. the associated Quote was accepted, rejected, or withdrawn). |
stagedQuoteState, quotationCart (the cart used to draft the negotiated pricing), sellerComment.3. Quote
Sent.| State | Description |
|---|---|
Pending | The quote has been sent to the buyer and is awaiting their response. |
Accepted | The buyer has accepted the quote (ready to be converted to an Order). |
Declined | The buyer has declined the quote. |
DeclinedForRenegotiation | The buyer has declined but requested a renegotiation (opens a new cycle). |
RenegotiationAddressed | The seller has acknowledged the renegotiation request. |
Withdrawn | The seller has withdrawn the quote before the buyer acted on it. |
quoteState, validTo (expiry date), buyerComment (on decline/renegotiation), sellerComment.4. Order (from Quote)
POST /orders/quotes with the accepted Quote's ID. The Order is created with the negotiated prices locked in from the Quote.Open → Confirmed → Complete, 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
| Actor | Actions |
|---|---|
| Buyer / Associate | Create QuoteRequest, cancel QuoteRequest, accept/decline Quote, create Order from accepted Quote |
| Sales Rep / Merchant | Accept/reject QuoteRequest, create & edit StagedQuote, create Quote (send to buyer), withdraw Quote |
Key API Endpoints
| Action | Endpoint |
|---|---|
| Create QuoteRequest | POST /as-associate/{associateId}/in-business-unit/key={buKey}/quote-requests |
| Create StagedQuote | POST /staged-quotes |
| Create Quote | POST /quotes |
| Create Order from Quote | POST /orders/quotes |
| Transition Quote state | POST /quotes/{id} with changeQuoteState action |
Implementation Notes
- All buyer-facing mutations (create QuoteRequest, accept/decline Quote, create Order from Quote) must go through the
asAssociateAPI 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 API — Patterns and Configuration
/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
{
"key": "monthly-subscription",
"schedule": {
"type": "standard",
"value": 1,
"intervalUnit": "Months"
}
}
{
"key": "first-of-month",
"schedule": {
"type": "dayOfMonth",
"day": 1
}
}
intervalUnit accepts "Days", "Weeks" (e.g. for weekly subscriptions), or "Months".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
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
recurrencePolicy reference field is supported on standalone prices.Cart Configuration — Adding Recurring Line Items
recurrenceInfo to mark it as recurring:{
"action": "addLineItem",
"productId": "...",
"variantId": 1,
"quantity": 1,
"recurrenceInfo": {
"recurrencePolicy": {
"typeId": "recurrence-policy",
"key": "monthly-subscription"
},
"priceSelectionMode": "Fixed"
}
}
priceSelectionMode
| Mode | Behavior |
|---|---|
"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. |
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:
| Endpoint | Result |
|---|---|
POST /orders (with recurring line items) | Creates both a regular order (immediate fulfillment) and a recurring order (future recurrences) |
POST /recurring-orders | Creates only a recurring order — no regular order is created |
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
startsAtfield with the future ISO 8601 date - The first order is still created immediately even when
startsAtis 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
POST /orders is called on this mixed cart, CT creates 3 orders:- A regular order containing all 3 items (for immediate fulfillment)
- A recurring order for the monthly subscription items
- 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
recurringOrderScope field that controls whether a discount applies to recurring order generations:recurringOrderScope value | Behavior |
|---|---|
"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 |
"NonRecurringOrdersOnly" for welcome/sign-up promotions. Use "AnyOrder" for standing loyalty discounts.{
"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.
{
"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
}
}
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:
- Fetch the recurring order:
GET /recurring-orders/{id}— the response contains acart.idfield - Apply Cart Update Actions to that cart ID directly (standard cart update endpoint)
- 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
- 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:
- The original recurring order → paused automatically
- A new recurring order is created immediately with the combined schedule
- This split is permanent — the paused order does not resume automatically
- Safe operation — does NOT create a new recurring order
- Safe operation — does NOT create a new recurring order
Cart Lifecycle for Recurring Orders
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: 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
Two custom fields are needed:
custom.distance— the distance value (same integer as the cart score)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
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 whenGet ShippingMethodsis 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.
Useful Links
- https://docs.commercetools.com/api/projects/predicates
- https://docs.commercetools.com/api/projects/shippingMethods#get-shippingmethod
- Get matching ShippingMethods for a Cart (the canonical predicate-evaluated lookup): https://docs.commercetools.com/api/projects/shippingMethods#get-matching-shippingmethods-for-a-cart (
/shipping-methods/matching-cart)
Store Credit Best Practices
Introduction
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
Approach 1: Extending the Customer Resource
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.
Approach 2: Leveraging Custom Objects
-
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.
-
Associate the Object to the Customer record using a Custom Field (e.g.,
storeCreditInfo) defined as a Reference to akey-value-document.
Approach 3: Using the Payment Methods API
-
Define a Custom Type: Create a Custom Type for the
payment-methodresource to hold non-native metadata such ashasStoreCredit(Boolean) andstoreCreditBalance(Money). -
Create the Payment Method: Generate a
PaymentMethodDraftto represent the Store Credit. Set themethodto a free-form label that identifies the instrument (for example, "StoreCredit") and include the tokenized account reference. -
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
Taxes — Cart Tax Modes and Integration Patterns
Cart Tax Modes
taxMode on a cart controls how taxes are calculated. Set at cart creation.| Mode | Description | Recommended for |
|---|---|---|
Platform | CT calculates tax using tax categories and rates configured in the project | Simple setups with static tax rates |
External | Tax rate provided per line item by the client | Dynamic rates from external engine, not recommended for complex scenarios |
ExternalAmount | Client provides the exact tax amount per line item | Recommended for most integrations — full control, consistent results |
Disabled | No tax calculation | B2B where taxes are handled externally |
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 labelamount: percentage (0.0–1.0, e.g., 0.19 for 19%)country: ISO 3166-1 alpha-2 country codestate(optional): for US state-level taxincludedInPrice: 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:
| Mode | Behavior |
|---|---|
| Line Item Level (default) | Tax computed on unitPrice × quantity — one calculation per line item |
| Unit Price Level | Tax computed per unit, then summed — can produce different rounding results |
Rounding Modes
| Mode | Behavior | Default |
|---|---|---|
HalfEven | Round half to nearest even digit (banker's rounding) | Yes |
HalfDown | Round 0.5 down | No |
HalfUp | Round 0.5 up | No |
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:
- Customer updates cart (adds item, changes address)
- CT triggers API Extension (synchronous)
- Extension calls tax service with cart contents + shipping address
- Tax service returns line-item-level tax amounts
- Extension returns
setLineItemTaxAmount/setCustomLineItemTaxAmountactions to CT - CT stores the exact tax amounts on the cart (
taxMode: ExternalAmount)
- 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:
- Storefront calls middleware instead of CT directly
- Middleware calls CT to get cart
- Middleware calls tax service
- Middleware calls CT with
setLineItemTaxAmountactions - 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.
| Factor | API Extension | External Microservice |
|---|---|---|
| Latency | Adds to checkout latency (2s limit) | No CT timeout constraint |
| Complexity | Simpler architecture | More moving parts |
| Fault tolerance | Extension failure = cart update failure | Middleware can retry, cache, degrade gracefully |
| Real-time tax | Always current | Depends on middleware caching policy |
AvaTax Integration Steps
- Determine which CT resources map to AvaTax entities (product → AvaTax tax code, address → jurisdiction)
- Set cart
taxMode: ExternalAmount - Call AvaTax
createTransactionAPI with cart line items and shipping destination - Map AvaTax line-level tax amounts back to CT
setLineItemTaxAmountupdate actions - Apply the tax amounts to the cart; CT stores them and includes in cart total
- On order creation, commit the AvaTax transaction for audit trail
Shipping Tiered Rates and Cart Score Setup
Problem Statement
-
How can I charge shipping based on the distance from the warehouse to the shipping address
-
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
Get ShippingMethods for a Cart is called.