Carts and Orders

Quantity limits for Line Items, Configurable price rounding modes for Carts, Cart merging for externally authenticated Customers, and Recurring Orders.

Ask about this Page
Copy for LLM
View as Markdown

After completing this page, you should be able to:

  • Describe the use of minCartQuantity and maxCartQuantity on Inventory Entries.
  • Explain the new minimum quantity requirement (1) for the removeLineItem action.
  • Differentiate between HalfEven, HalfUp, and HalfDown rounding modes and how priceRoundingMode on a Cart ensures consistency.
  • Use the Merge Cart feature (MergeCartDraft) to combine an Anonymous Cart with a Customer Cart after authentication.
  • Utilize the Recurring Orders API to manage subscription-based purchases, including creating recurrence policies and handling expiration and failure notifications.

Quantity limits for Line Items

Previously, commercetools allowed merchants to track total stock but didn't provide a native way to restrict the specific quantity of a product a single customer could add to their cart. This release introduces minCartQuantity and maxCartQuantity fields, allowing for more granular control over purchase behavior directly at the inventory level.

Why use this?

  • Wholesale/B2B: enforce minimum order quantities for specific items.
  • Inventory protection: prevent "denial of inventory" attacks where one user locks up all available stock in their cart.
  • Limited releases: limit high-demand items (for example, "Max two per customer") to ensure fair distribution.
Example:

Let’s do a quick test and set a quantity limit for the Sweet Pea Candle (SKU: SPC-01) where each customer has to buy at least four of them but not more than 20 and see what happens when we try to only add two of them to our Cart.

Of course, to update an inventory entry, we have to find it first. To keep things simple, let’s pick the inventory entry that is not bound to any specific supply channel:

Set quantity limit
// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.

/**
 * Updates the min and max quantity for a specific SKU.
 */
async function setMinMaxQuantityForSku(
  sku: string,
  min: number,
  max: number
): Promise<any> {
  const response = await apiRoot
    .inventory()
    .get({ queryArgs: { where: `sku="${sku}" and supplyChannel is not defined` } })
    .execute();

  const entry = response.body.results[0];

  return apiRoot
    .inventory()
    .withId({ ID: entry.id })
    .post({
      body: {
        version: entry.version,
        actions: [
          {
            action: "setInventoryLimits",
            minCartQuantity: min,
            maxCartQuantity: max,
          },
        ],
      },
    })
    .execute();
}

/**
 * Creates a Cart with the specified SKU.
 */
async function createCart(sku: string, quantity: number): Promise<any> {
  return apiRoot
    .carts()
    .post({
      body: {
        currency: "EUR",
        country: "DE",
        lineItems: [{ sku, quantity }],
      },
    })
    .execute();
}

// Execute the logic
async function run() {
  const targetSku = "SPC-01";

  try {
    // 1. Set Limits
    await setMinMaxQuantityForSku(targetSku, 4, 20);
    console.log(`Limits updated! Min: 4, Max: 20`);

    // 2. Create Cart with quantity (this will fail as 2 < 4)
    const cartResponse = await createCart(targetSku, 2);
    console.log(
      "Cart created successfully!",
      JSON.stringify(cartResponse.body.lineItems, null, 2)
    );
  } catch (error: any) {
    // --- READABLE ERROR HANDLING ---
    console.error("\nREQUEST FAILED");
    if (error.body && error.body.errors) {
      console.error(`Status Code: ${error.statusCode}`);
      error.body.errors.forEach((err: any) => {
        console.error(`Field: ${err.code} | Message: ${err.message}`);
      });
    } else {
      console.error("System Error:", error.message);
    }
  }
}

run();

Resultbash
REQUEST FAILED
Status Code: 400
Field: LineItemQuantityBelowLimit | Message: Quantity '2' less than minimum '4'.

Looks like everything worked as expected and we couldn't add the Sweet Pea Candle to our Cart in a quantity that doesn't fall within the allowed range.

Speaking of Line Item quantity, it's worth noting that commercetools now requires a minimum quantity of 1 for the removeLineItem update action. Specifying 0 will now throw a “400 Bad Request error”. If you omit the quantity field entirely, it still removes the whole line item, which brings this validation in line with the Shopping Lists API.

Configurable price rounding modes for Carts

If you've ever battled your Finance team or an ERP integration partner over a €0.01 discrepancy in a Cart total, this section is for you.
In 2025 we have added a new roundingMode field, available as a Project setting and on the following resources: Cart, CartDraft, Order, OrderImportDraft, Quote, and QuoteRequest.

This feature allows you to precisely define how rounding is applied to line item totals, tax calculations, and discount applications, ensuring consistency with your accounting requirements.

The supported modes are:

  • HalfEven: the classic commercetools behavior (Default).
  • HalfUp: rounds to the "nearest neighbor," rounding up if both neighbors are equidistant.
  • HalfDown: rounds to the "nearest neighbor," rounding down if both neighbors are equidistant.
Example:

Let’s create three Carts containing the same items:

  • Evergreen Candle (SKU: EC-0993)
  • Wine Bottle Opener (SKU: WOP-09)
  • Ice Bucket (SKU: BUCK-023)
  • Willow Teapot (SKU: WTP-09)
Based on the items selected, it appears our Cart will qualify for one of the previously configured Cart Discounts. The resulting carts will be identical, with the only difference being the priceRoundingMode set for each:
Create three carts with the same items and different `priceRoundingMode`
// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.

const SKUS = ["EC-0993", "WOP-09", "WTP-09", "BUCK-023"];
const CURRENCY = "EUR";
const COUNTRY = "DE";

type RoundingMode = "HalfEven" | "HalfUp" | "HalfDown";

/**
 * Creates a Cart with the given rounding mode and predefined SKUs.
 */
async function createCart(roundingMode: RoundingMode): Promise<any> {
  return apiRoot
    .carts()
    .post({
      body: {
        currency: CURRENCY,
        country: COUNTRY,
        priceRoundingMode: roundingMode,
        lineItems: SKUS.map((sku) => ({ sku })),
      },
    })
    .execute();
}

function formatMoney(centAmount: number): string {
  return `${(centAmount / 100).toFixed(2)} ${CURRENCY}`;
}

// Execute the demo
async function run(): Promise<void> {
  const modes: RoundingMode[] = ["HalfEven", "HalfUp", "HalfDown"];

  console.log("=== Price Rounding Modes Demo ===\n");

  try {
    for (const mode of modes) {
      console.log(`Creating cart with rounding mode: ${mode} ...`);
      const response = await createCart(mode);
      const cart = response.body;
      console.log(
        `✓ Cart created | Mode: ${mode} | ID: ${cart.id} | Total: ${formatMoney(
          cart.totalPrice.centAmount
        )}`
      );
    }
  } catch (error: any) {
    console.error("\nCART CREATION FAILED");
    if (error.body && error.body.errors) {
      console.error(`Status Code: ${error.statusCode}`);
      error.body.errors.forEach((err: any) => {
        console.error(`Field: ${err.code} | Message: ${err.message}`);
      });
    } else {
      console.error("System Error:", error.message);
    }
  }
}

run();
Resultbash
=== Price Rounding Modes Demo ===

Creating cart with rounding mode: HalfEven ...
 Cart created | Mode: HalfEven   | ID: 837e6b2b-4a71-426b-b620-2c71bfb79d8c | Total: 15.76 EUR
Creating cart with rounding mode: HalfUp ...
 Cart created | Mode: HalfUp     | ID: d6f8dcc7-8bc9-40fb-8746-74ff13558e3e | Total: 15.76 EUR
Creating cart with rounding mode: HalfDown ...
 Cart created | Mode: HalfDown   | ID: d9e1cca8-7d70-462e-b031-eba493903987 | Total: 15.75 EUR

The result clearly shows that the chosen rounding mode can lead to minor variations in the final results. Ultimately, choosing the right price rounding mode is key to ensuring that your price calculations are consistent and reliable across all your integrated systems.

Cart merging for externally authenticated Customers

We've all run into this: a customer shops on their phone as a "Guest," adds a few things to their cart, and then finally logs in. Poof! The items vanish, or even worse, they wipe out the stuff the customer had saved in their account last week.

This "Cart Merging" scenario is a classic in commerce.

Previously, the automatic "Merge Cart" logic was tightly coupled with the commercetools native password flow.

If you used the /login endpoint (our native auth), you could just pass the anonymousCartId, and the platform would smoothly merge it with the customer's existing Cart.
But if you were using an External OAuth/OIDC flow, you had to authenticate the user, grab the token, and then manually move items from the guest Cart to the customer Cart with some custom code. That was super error-prone and could really slow things down.
Good news: We've now rolled out a dedicated Merge Cart feature specifically for customers who log in externally. You can now use the same reliable merging logic (Guest ➜ Customer) even if they didn't come through the commercetools password endpoint.
Example:
Let's walk through a straightforward example: we will create a customer Cart and an anonymous Cart, then use the new Merge Cart feature to combine them.
Create and merge a customer Cart and an anonymous Cart
// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.

// ID of a customer belonging to our project
const CUSTOMER_ID = "46fbc1a6-fd3c-424c-bbf5-096ab5da0f21";

/**
 * Creates a customer Cart with a single Line Item.
 */
async function createCustomerCart(sku: string, quantity: number): Promise<any> {
  return apiRoot
    .carts()
    .post({
      body: {
        currency: "EUR",
        country: "DE",
        customerId: CUSTOMER_ID,
        lineItems: [{ sku, quantity }],
      },
    })
    .execute();
}

/**
 * Creates an anonymous Cart with a single Line Item.
 */
async function createAnonymousCart(
  sku: string,
  quantity: number
): Promise<any> {
  return apiRoot
    .carts()
    .post({
      body: {
        currency: "EUR",
        country: "DE",
        lineItems: [{ sku, quantity }],
      },
    })
    .execute();
}

/**
 * Merges an anonymous Cart into a customer Cart.
 */
async function mergeCartsForCustomer(anonymousCartId: string): Promise<any> {
  return apiRoot
    .carts()
    .customerIdWithCustomerIdValueMerge({ customerId: CUSTOMER_ID })
    .post({
      body: {
        anonymousCart: { typeId: "cart", id: anonymousCartId },
        mergeMode: "MergeWithExistingCustomerCart",
        updateProductData: false,
      },
    })
    .execute();
}

/**
 * Prints a formatted summary of the Cart merge operation.
 */
function printMergeSummary(
  customerCartId: string,
  anonymousCartId: string,
  mergedCart: any
): void {
  console.log("=== Final Result ===");
  console.log(`Customer Cart ID: ${customerCartId}`);
  console.log(`Anonymous Cart ID: ${anonymousCartId}`);
  console.log(`\nMerged Cart ID: ${mergedCart.id}`);
  console.log(`Total Quantity: ${mergedCart.totalLineItemQuantity}`);
  console.log("\nLine Items in Merged Cart:");
  mergedCart.lineItems.forEach((item: any, index: number) => {
    console.log(
      `  ${index + 1}. Product: ${item.productKey}, Quantity: ${
        item.quantity
      }, SKU: ${item.variant.sku || "N/A"}`
    );
  });
}

// Execute the logic
async function run() {
  try {
    console.log("=== Cart Merge Demo ===\n");

    // Step 1: Create customer Cart
    console.log("Step 1: Creating customer cart...");
    const customerCartResponse = await createCustomerCart("CST-01", 2);
    const customerCartId = customerCartResponse.body.id;
    console.log(`✓ Customer cart created with ID: ${customerCartId}\n`);

    // Step 2: Create anonymous Cart
    console.log("Step 2: Creating anonymous cart...");
    const anonymousCartResponse = await createAnonymousCart("CDG-09", 1);
    const anonymousCartId = anonymousCartResponse.body.id;
    console.log(`✓ Anonymous cart created with ID: ${anonymousCartId}\n`);

    // Step 3: Merge Carts
    console.log("Step 3: Merging anonymous cart into customer cart...");
    const mergedCartResponse = await mergeCartsForCustomer(anonymousCartId);
    console.log(`✓ Carts merged successfully!\n`);

    // Step 4: Print summary
    printMergeSummary(customerCartId, anonymousCartId, mergedCartResponse.body);
  } catch (error: any) {
    // --- READABLE ERROR HANDLING ---
    console.error("\nCART MERGE FAILED");
    if (error.body && error.body.errors) {
      console.error(`Status Code: ${error.statusCode}`);
      error.body.errors.forEach((err: any) => {
        console.error(`Field: ${err.code} | Message: ${err.message}`);
      });
    } else {
      console.error("System Error:", error.message);
    }
  }
}

run();

Resultbash
...

=== Final Result ===
Customer Cart ID: 121aab26-9ad6-4a95-bf86-2f3f1c139e19
Anonymous Cart ID: 22f58524-45c3-49c1-b89c-21ad5654b9c4

Merged Cart ID: 121aab26-9ad6-4a95-bf86-2f3f1c139e19
Total Quantity: 3

Line Items in Merged Cart:
  1. Product: classic-serving-tray, Quantity: 2, SKU: CST-01
  2. Product: crystal-drinking-glass, Quantity: 1, SKU: CDG-09
The results clearly show that the two Carts were merged, exactly like if we would be using the /login endpoint. Since we did not specify a Cart merging strategy, MergeWithExistingCustomerCart was used as the default.

Recurring Orders

For a long time, implementing subscription models in commercetools required external "crons" or middleware to clone Carts and trigger order creation at specific intervals. With the 2025 release of Recurring Orders, commercetools moved this logic natively into the platform, allowing developers to define automated order schedules directly within the API.

The Recurring Orders framework relies on three primary components working in tandem:

The Recurrence Policy

The Recurrence Policy defines the "when." It is a standalone resource that dictates the frequency of the subscription (for example, every 30 days, every first of the month).
  • Schedule Type: currently supports standard intervals (Days, Weeks, Months).
  • Reusability: a single policy (like "Standard Monthly") can be referenced by thousands of different recurring orders.

The right price

After establishing at least one Recurrence Policy, we must define recurring prices for the products intended for subscription sales. These prices can be either Embedded or Standalone, provided they reference a previously defined Recurrence Policy.

The blueprint Cart

Unlike standard checkouts, a Recurring Order is initialized from a Cart that acts as a template. This Cart contains the shipping address, payment info, and the specific Line Items intended for the subscription.
  • Recurrence Info: Line Items in these Carts must include recurrenceInfo, which links the item to a specific Recurrence Policy.

The Recurring Order resource

The Recurring Order is the "engine." When you create this resource, you point it to your blueprint Cart. commercetools then uses this blueprint to automatically generate new Orders at the intervals defined by the policy.
Example:

To properly set up and use this exciting new functionality, you have to follow these steps:

  • Define a Recurrence Policy/interval for the subscription schedule (the "heartbeat"—daily, weekly, monthly).
  • Add at least one price to your Product Variant that is tied to the Recurrence Policy (a "subscription price" if you will).
  • Add the Product Variant to a customer Cart, specifying that the Line Item should use the price tied to a Recurrence Policy.
  • Create a Recurring Order from that Cart using the /recurring-orders endpoint.

Let’s go ahead and give it a try by coding the steps described above

Create a Recurring Order
// Ensure all necessary imports are included and the API client is instantiated as demonstrated in the first example.

const CUSTOMER_ID = "46fbc1a6-fd3c-424c-bbf5-096ab5da0f21";
const SKU = "WTP-09";
const CURRENCY = "EUR";
const COUNTRY = "DE";
const PRICE_AMOUNT = 599; // in cents
const RECURRENCE_POLICY_KEY = "daily-subscription";

const expiryInDays = (days: number): string =>
  new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();

const formatMoney = (centAmount: number): string =>
  `${(centAmount / 100).toFixed(2)} ${CURRENCY}`;

/**
 * Creates a daily Recurrence Policy.
 */
async function createRecurrencePolicy(): Promise<any> {
  return apiRoot
    .recurrencePolicies()
    .post({
      body: {
        key: RECURRENCE_POLICY_KEY,
        schedule: {
          type: "standard",
          intervalUnit: "Days",
          value: 1,
        },
      },
    })
    .execute();
}

/**
 * Adds an embedded price to a Product Variant and ties it to the Recurrence Policy.
 */
async function addPriceToVariant(
  productId: string,
  productVersion: number,
  recurrencePolicyId: string
): Promise<any> {
  return apiRoot
    .products()
    .withId({ ID: productId })
    .post({
      body: {
        version: productVersion,
        actions: [
          {
            action: "addPrice",
            sku: SKU,
            price: {
              value: {
                type: "centPrecision",
                currencyCode: CURRENCY,
                centAmount: PRICE_AMOUNT,
              },
              country: COUNTRY,
              recurrencePolicy: {
                typeId: "recurrence-policy",
                id: recurrencePolicyId,
              },
            },
          },
          { action: "publish" },
        ],
      },
    })
    .execute();
}

/**
 * Fetches a product by SKU to get its ID and variant details.
 */
async function getProductBySku(sku: string): Promise<any> {
  const response = await apiRoot
    .productProjections()
    .get({
      queryArgs: {
        where: `masterVariant(sku="${sku}")`,
      },
    })
    .execute();

  if (response.body.results.length === 0) {
    throw new Error(`Product with SKU "${sku}" not found`);
  }

  return response.body.results[0];
}

/**
 * Creates a customer Cart with a specific SKU and selects "subscription price".
 */
async function createCustomerCart(
  sku: string,
  recurrencePolicyId: string
): Promise<any> {
  return apiRoot
    .carts()
    .post({
      body: {
        currency: CURRENCY,
        country: COUNTRY,
        customerId: CUSTOMER_ID,
        lineItems: [
          {
            sku,
            recurrenceInfo: {
              recurrencePolicy: {
                typeId: "recurrence-policy",
                id: recurrencePolicyId,
              },
              priceSelectionMode: "Fixed", // or "Dynamic"
            },
          },
        ],
        shippingAddress: {
          country: COUNTRY,
        },
      },
    })
    .execute();
}

/**
 * Creates a Recurring Order from a Cart.
 */
async function createRecurringOrder(
  cartId: string,
  cartVersion: number
): Promise<any> {
  return apiRoot
    .recurringOrders()
    .post({
      body: {
        cart: {
          typeId: "cart",
          id: cartId,
        },
        cartVersion: cartVersion,
        expiresAt: expiryInDays(5),
      },
    })
    .execute();
}

// Execute the demo
async function run(): Promise<void> {
  console.log("=== Recurring Orders Demo ===\n");

  try {
    // Step 1: Create Recurrence Policy
    console.log("Step 1: Creating daily recurrence policy...");
    const recurrencePolicyResponse = await createRecurrencePolicy();
    const recurrencePolicy = recurrencePolicyResponse.body;
    const recurrencePolicyId = recurrencePolicy.id;
    console.log(`✓ Recurrence Policy created | ID: ${recurrencePolicyId}\n`);

    // Step 2: Get Product by SKU and add price
    console.log("Step 2: Fetching product and adding price...");
    const product = await getProductBySku(SKU);
    const { id: productId, version: productVersion } = product;

    await addPriceToVariant(productId, productVersion, recurrencePolicyId);
    console.log(
      `✓ Price added to variant | Product ID: ${productId} | Variant SKU: ${SKU}\n`
    );

    // Step 3: Create customer Cart
    console.log("Step 3: Creating customer cart...");
    const cartResponse = await createCustomerCart(SKU, recurrencePolicyId);
    const cart = cartResponse.body;
    const cartId = cart.id;
    const cartVersion = cart.version;
    console.log(
      `✓ Customer cart created | ID: ${cartId} | Total: ${formatMoney(
        cart.totalPrice.centAmount
      )}\n`
    );

    // Step 4: Create Recurring Order
    console.log("Step 4: Creating recurring order...");
    const recurringOrderResponse = await createRecurringOrder(
      cartId,
      cartVersion
    );
    const recurringOrder = recurringOrderResponse.body;
    console.log(`✓ Recurring order created | ID: ${recurringOrder.id}\n`);
  } catch (error: any) {
    console.error("\nRECURRING ORDER CREATION FAILED");
    if (error.body && error.body.errors) {
      console.error(`Status Code: ${error.statusCode}`);
      error.body.errors.forEach((err: any) => {
        console.error(`Field: ${err.code} | Message: ${err.message}`);
      });
    } else {
      console.error("System Error:", error.message);
    }
  }
}

run();

Resultbash
=== Recurring Orders Demo ===

Step 1: Creating daily recurrence policy...
 Recurrence Policy created | ID: a713e79b-9c76-4b26-bdf3-f1365c7bf7ae

Step 2: Fetching product and adding price...
 Price added to variant | SKU: WTP-09

Step 3: Creating customer cart...
 Cart created | ID: de80c89b-34cb-4405-a442-e9dcca1cf6a9 | Total: 5.99 EUR

Step 4: Creating recurring order...
 Recurring order created | ID: 31cb251c-87a8-4c5a-9a56-6660d5f0195e
The expected outcome is a recurring order visible in the Merchant Center under Orders ➜ Recurring Orders. You will find the newly created Recurring Order listed there.

As the code indicates, the subscription is configured to expire five days from the current date, which is displayed on the order's details page. This page also allows for managing the subscription, such as skipping occurrences or pausing/resuming the service. The "Linked Orders" tab tracks all individual orders generated under this recurring subscription. Currently, only one order is present (it might take a few minutes to show up), but more will appear daily, reflecting the set recurrence policy.

You might have noticed we set the priceSelectionMode to "Fixed" when adding a Line Item to the Cart. That means the customer pays the original price every time the subscription renews, even if the product's price changes later. The other option is "Dynamic," which always grabs the current price when a new order is created for the subscription.
You can whip up a Recurring Order using the Recurring Orders API or the Orders API. The Recurring Orders API offers a bit more wiggle room for setting up your order, like pushing back the start date, scheduling an end date, and adding Custom Fields. Check out the Recurring Orders overview for the lowdown on the different ways to do this.

Test your knowledge