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:
- 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.
- 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 configconst 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 consistentlet 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 drawerrenderCartDrawer(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 cartconst cart = await callFirmly(`/cart`, null, 'GET');renderCartDrawer(cart);// Buyer clicks "+1" on the hoodie lineconst 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 socksconst 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 pageshowStockMessage(`${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.
Related
- Single Product Purchase — the simpler shape
- Buyer Purchase Flow — the ad → PDP → checkout → thank-you walkthrough from the buyer’s point of view
- Why Firmly for Branded Commerce — what makes Branded Commerce different
- Operations — onboarding partners, dashboard, operational workflow