Multi-Product Purchase
Scope
Multi-product carts in Agentic Commerce contain items from a single merchant. The user discovers items iteratively through the conversation, reviews the cart, then checks out once.
What’s different from single-product
Two things change:
- Iterative Discovery — Discovery (Phase 3) repeats. The user adds an item, the agent confirms, the user is asked if they want more. The cart grows over multiple conversation turns.
- Cart Review — a new phase between Discovery and Checkout where the user can adjust quantities and remove items before paying.
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 | Once | Iterative — repeats per item |
| 3.5 Cart Review | — | New — user adjusts before checkout |
| 4. Checkout | One pass | Same one pass, but cart has many line items |
| 5. Order placement | Same | Same |
Sequence diagram
Iterative discovery in code
Each item the user names goes through a search → add cycle. The cart accumulates across calls.
// User: "add a hoodie and a beanie"const items = [{ query: 'black hoodie medium' },{ query: 'wool beanie' }];// Searches are independent — run them in parallel.const searches = await Promise.all(items.map((item) =>callFirmly(`/api/v1/discovery/search`, { query: item.query, page_size: 5 })));// Cart mutations are NOT independent — every add returns the full cart and// concurrent writes to the same cart race. Add serially.const adds = [];for (let i = 0; i < items.length; i++) {const variant = searches[i].products?.[0]?.variants?.[0];if (!variant) {adds.push({ skipped: items[i].query });continue;}const cart = await callFirmly(`/cart/line-items`, {add_to_cart_ref: variant.add_to_cart_ref,quantity: 1});adds.push({ added: variant, cart });}// Report back to userconst added = adds.filter(a => a.added);const skipped = adds.filter(a => a.skipped);agent.say(`Added ${added.length} items to your cart` +(skipped.length ? `. Couldn't find: ${skipped.map(s => s.skipped).join(', ')}` : '.'));
After each add, your agent can either continue discovery (“Anything else?”) or move to cart review.
Cart Review in detail
Before checkout, the user sees the full cart and can change it. There are two surface modes — pick whichever fits your agent:
Mode A — Chat (text-only surfaces)
The agent presents the cart as structured text with a checkout link. The user can say things like “remove the socks” or “change shoes to size 11”. Your agent updates the cart and re-presents it.
// Read the current cartconst cart = await callFirmly(`/cart`, null, 'GET');// Format for the useragent.say(formatCart(cart));// → "1× Luma Hoodie (M, Black) — $45// 1× Luma Beanie — $18// 1× Luma Tee (L, White) — $22// Subtotal: $85// Anything to change?"// User: "remove the tee"// Match on the variant SKU (line_items expose `sku` / `base_sku`, not a product_id)const teeLineItem = cart.line_items.find(li => li.sku === 'tee-white-l');await callFirmly(`/cart/line-items/${teeLineItem.line_item_id}`, { quantity: 0 }, 'PUT');// Or set quantity = 0 to remove; or use DELETE for full clear
Mode B — Cart panel (rich surfaces)
A cart panel slides into the surface (iframe, drawer, modal) showing all items with images, quantity controls, and remove buttons. The user adjusts directly in the panel. When satisfied, they click “Checkout” → checkout begins.
If you’re using Embedded Checkout, the panel can listen to firmly::CartUpdated events to stay in sync.
Common cart modifications
Endpoint paths below are shown in shorthand. Every cart call is scoped to a domain — the full path is /api/v2/domains/{domain}/cart/... (see the single-product flow for fully-qualified examples).
| Operation | Endpoint | Body |
|---|---|---|
| Read the cart | GET /cart |
— |
| 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) |
— |
| 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
There is one shipping address for the cart, since all items come from one merchant — but shipping methods are chosen per shipment. A single-shipment cart has one method to pick; a multi-shipment cart has one per shipment_id.
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:
- PayPal Express — one PayPal handoff for the whole cart total
- Click to Pay — one Click to Pay auth covers the whole cart
- Klarna BNPL — one Klarna agreement spans all items
This is the same as single-product; the user 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) {// NotEnoughStockError is the wire error name (HTTP 409) — see the errors catalogif (err.error === 'NotEnoughStockError') {// Re-fetch the cart and re-read it; a line that dropped out of stock// surfaces as a cart notice on the affected line item.const updated = await callFirmly(`/cart`, null, 'GET');const affected = updated.line_items.find(li => li.notices?.length);// Ask the user how to handle itagent.say(`${affected?.description ?? 'An item'} just went out of stock. ` +`Drop it from the order, or pick a different variant?`);// ... handle user response}throw err;}}
See Errors & Conventions → Recovery patterns for more.
The checkout tail is identical to single-product
After cart review, checkout runs the same sequence as the single-product flow: set shipping info → set shipping method per shipment (from each shipment’s shipping_method_options) → get/set merchant consents → get payment public key → complete order (v2, with encrypted_card). Because the cart was built over the previous calls, finalize it with complete-order (existing cart), not place-order (the one-shot create-cart-and-order call). If the merchant requires consents and you skip them, complete-order returns RequiredConsentsNotSigned (412). See the single-product flow’s checkout steps for the fully-worked tail.
Related
- Single-product purchase flow — the checkout tail in full detail
- Cart API guide — multi-step cart, shipments, addons
- Shipping & Fulfillment — multi-shipment carts
- Errors & conventions — recovery patterns for every call above