Docs
Firmly Agentic Commerce
Set theme to dark (⇧+D)

Multi-Product Purchase

​​ Scope

Multi-product carts in Branded Commerce contain items from a single merchant. Common entry points: a curated landing page (e.g., “Holiday Gift Guide” featuring 6 products), or upsell modules on a branded PDP (“Customers also bought…”).

​​ What’s different from single-product

Two things change:

  1. Multi-item discovery — the buyer either lands on a curated landing page with multiple products (one click → multiple items in cart), or adds related items from PDPs.
  2. Cart Review — a step between Discovery and Checkout where the buyer reviews and adjusts the cart before paying. In Branded Commerce this is typically a cart drawer or dedicated cart page on the partner’s branded surface.

Everything else — Hosting, Authentication, Checkout, Order placement — is identical to Single Product.

​​ The six phases (single + Cart Review)

Phase Single-Product Multi-Product
1. Hosting Same Same
2. Authentication Same Same
3. Discovery Page-load context, single SKU Curated landing or related-product upsell — multiple SKUs
3.5 Cart Review — New — buyer adjusts before checkout
4. Checkout One pass Same one pass, but cart has many line items
5. Order placement Same Same

​​ Sequence diagram

​​ Multi-item discovery in code

For a curated landing page where the partner pre-selected the products, all SKUs are known at page load. Add them one after another — each add mutates the same cart, so serial calls keep cart state consistent:


// Page knows the SKUs from the curated landing config
const skus = [
{ add_to_cart_ref: { variant_id: 'hoodie-black-m' }, quantity: 1 },
{ add_to_cart_ref: { variant_id: 'beanie-wool' }, quantity: 1 },
{ add_to_cart_ref: { variant_id: 'socks-pack' }, quantity: 2 }
];
// Add each — sequential to ensure cart state is consistent
let cart;
for (const item of skus) {
cart = await callFirmly(`/api/v2/domains/${DOMAIN}/cart/line-items`, item);
}
// Cart now has all items; render the branded cart drawer
renderCartDrawer(cart);

For a related-product upsell on a single PDP, the buyer adds one product, then sees recommended products. Each Add is a separate POST /cart/line-items call, with the cart drawer updating after each.

​​ Cart Review in detail

Before checkout, the buyer sees the full cart and can change it. Branded Commerce supports two surface modes:

​​ Mode A — Cart drawer / slide-in panel

The branded page renders a cart panel that slides in from the side. Quantities can be incremented/decremented inline, items can be removed, promo codes can be applied. The panel listens for cart-update responses and re-renders.


// Read the current cart
const cart = await callFirmly(`/cart`, null, 'GET');
renderCartDrawer(cart);
// Buyer clicks "+1" on the hoodie line
const hoodieLine = cart.line_items.find(li => li.line_item_id === 'li_hoodie');
const updated = await callFirmly(
`/cart/line-items/${hoodieLine.line_item_id}`,
{ quantity: hoodieLine.quantity + 1 },
'PUT'
);
renderCartDrawer(updated);
// Buyer clicks Remove on the socks
const socksLine = updated.line_items.find(li => li.line_item_id === 'li_socks');
await callFirmly(`/cart/line-items/${socksLine.line_item_id}`, { quantity: 0 }, 'PUT');

​​ Mode B — Dedicated cart page

The buyer navigates to a dedicated /cart page on the branded surface. Full quantity controls, line-item images, subtotal/tax/shipping/total breakdown, promo-code input, and the Checkout button.

This is the more common pattern for Branded Commerce since the surface is a full website, not a chat or app.

​​ Common cart modifications

Operation Endpoint Body
Add a line item POST /cart/line-items { add_to_cart_ref, quantity }
Change quantity PUT /cart/line-items/:id { quantity: N }
Remove an item PUT /cart/line-items/:id with { quantity: 0 } (no per-item DELETE; DELETE /cart/line-items clears the whole cart) { quantity: 0 }
Clear the entire cart DELETE /cart/line-items —
Apply a promo code POST /cart/promo-codes { promo_codes: ["CODE1"] }
Remove all promo codes DELETE /cart/promo-codes —

​​ Shipping in multi-product carts

Shipping uses one shipping address for the whole cart, since all items come from one merchant. Shipping methods are chosen per shipment: a single-warehouse cart has exactly one shipment, so it looks like one cart-level choice.

For items that physically ship separately (multi-shipment) — same merchant, different warehouses or fulfillment dates — Firmly groups them into multiple shipment_ids in the cart. Walk each one:


const cart = await callFirmly(`/cart/shipping-info`, address);
for (const shipment of cart.shipments) {
// Shipping methods are populated inline on each shipment after set-shipping-info.
const cheapest = shipment.shipping_method_options
.sort((a, b) => a.price.value - b.price.value)[0];
await callFirmly(`/cart/shipment/methods`, {
shipment_id: shipment.shipment_id,
shipping_method_id: cheapest.id
});
}

See Multi-Shipment for the details.

​​ Express checkout shortcuts

For multi-product carts, express checkout options apply to the full cart, not per-item:

  • Click to Pay (Visa/Mastercard) — one Click to Pay auth covers the whole cart
  • PayPal — one PayPal handoff for the whole cart total
  • Google Pay (planned) — one wallet confirmation for the whole cart

This matches the live payment methods on the buyer-facing pages: card, Click to Pay, and PayPal are live, Google Pay is planned. As with single-product, the buyer sees one payment confirmation for everything.

​​ Race conditions to handle

Multi-product flows are more exposed to mid-flow stock changes because there are more items and more time between discovery and checkout.


async function placeOrderWithRetry(cart) {
try {
// The cart was built over the previous calls, so finalize it with complete-order
// (body = { encrypted_card, billing_info }); place-order would flush and rebuild it.
return await callFirmly(`/payment/complete-order`, buildOrder(cart));
} catch (err) {
// The envelope's `code` is the numeric HTTP status; the stable name is in `error`.
if (err.error === 'NotEnoughStockError') {
const updated = await callFirmly(`/cart`, null, 'GET');
// The cart's notices flag the affected line; map it back to its line item by id.
const outOfStock = updated.notices?.find(n => n.code === 'ITEM_OUT_OF_STOCK');
const affectedLine = updated.line_items.find(li => li.line_item_id === outOfStock?.item_id);
// Surface to the buyer on the branded checkout page
showStockMessage(`${affectedLine?.sku} just went out of stock. Drop it or pick a different variant?`);
// ... handle buyer choice
}
throw err;
}
}

See Errors & Conventions → Recovery patterns for more.

​​ Attribution in multi-product carts

The attribution model is identical to single-product — UTM, click IDs, and partner identifier pass through to the merchant’s OMS on complete-order. The merchant sees one order with multiple line items, all tagged with the same attribution data.