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

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:

  1. 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.
  2. 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 user
const 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 cart
const cart = await callFirmly(`/cart`, null, 'GET');
// Format for the user
agent.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 catalog
if (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 it
agent.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.