Chat to Checkout
The Advanced Checkout Guide shows the happy path: clear intent, one matching product, no errors. This page covers the messy reality of conversational checkout — ambiguous intent, multiple matches, mid-flow corrections, errors recovered through dialogue.
The pattern
Every conversational purchase moves through five micro-phases that are different from the API-level phases:
- Intent — user expresses what they want, in natural language
- Disambiguation — agent narrows it down to one product, variant, quantity
- Confirmation — agent surfaces what’s about to happen and gets explicit consent
- Execution — agent calls Firmly APIs to place the order
- Result — agent surfaces outcome (success, error, partial recovery)
The first three happen on your side. Firmly only sees phase 4.
Worked example — a real chat sequence
The dialogue below is annotated with the API calls your agent makes at each step.
User: Hey, can you order me a black hoodie?Agent: One sec, let me look.→ POST /api/v1/discovery/search { query: "black hoodie" }Response: 4 products from the target merchant, varying pricesAgent: Here's what I found:1. Luma Classic Hoodie — Black — $452. Luma Tech Hoodie — Black — $893. Luma Premium Heavy Hoodie — Black — $1204. Luma Hoodie — Black/Gray — $55Which one — or want me to read more details?User: The classic one. Medium.Agent: One Luma Classic Hoodie, Black, size Medium for $45 — shipping toyour usual address (123 Main St, Brooklyn NY). Total with shippingand tax will be around $52. Want me to place the order?[Internally, agent has already done the cart preview:]→ POST /cart/line-items { add_to_cart_ref: {…size:M, color:black}, quantity: 1 }→ POST /cart/shipping-info { …saved address } (response carries shipping_method_options per shipment)→ POST /cart/shipment/methods { shipment_id, shipping_method_id }Response: total = $52.13, shipping_method = "Standard $5", tax = "$2.13"User: Yes go aheadAgent: Placing the order now...→ GET /cart/consents (fetch any merchant-required consents)→ PUT /cart/consents (record the buyer's acceptance if the merchant requires it)→ GET /payment/key (cached)→ POST /payment/complete-order { encrypted_card, billing_info } (finalizes the cart built above)Response: { cart_status: "submitted",cart_id: "03798510-b778-4742-ba25-5812a552507f",platform_order_number: "29185",urls: { order_status_page: "https://merchant.example/orders/..." },custom_properties: { order_token: "..." },total: { value: 52.13, symbol: "$", currency: "USD" } }Agent: Done! Your order is in for $52.13. It'll ship in 2–3 businessdays — I'll send you the merchant's order page link if you want.
This is the pattern your agent should reach for. Six user-visible turns, eight API calls, full consent, recoverable at every step.
The four hard parts
1. Disambiguation
When discovery/search returns multiple products, the user has to pick one. There’s no winning shortcut here — your agent’s job is to present options in a way the user can scan and choose.
Pattern: cap the visible list at 3–5 items, sorted by relevance or popularity. Offer to read details on any of them. Avoid auto-picking the top result unless the user explicitly delegates (“just get me whatever you think”).
const search = await callFirmly('/api/v1/discovery/search', {query: userIntent.query,page_size: 5});if (!search.products?.length) {return reply(`I couldn't find anything matching "${userIntent.query}".`);}if (search.products.length === 1) {return confirmSingle(search.products[0]);}return reply(formatProductList(search.products.slice(0, 5)));
2. Variant resolution
A single product may have many variants (sizes, colors, materials). The user may have specified some (“medium”, “in black”) and not others. Walk the variant axes:
function resolveVariant(product, userHints) {// Variants carry `variant_option_list`: a flat array of option strings// (e.g. ["Black", "Medium"]) with no axis labels. Match each hint value// against any entry in that array.const hintValues = Object.values(userHints);const candidates = product.variants.filter(v =>hintValues.every(value =>(v.variant_option_list || []).some(opt =>opt?.toLowerCase().includes(value.toLowerCase()))));if (candidates.length === 1) return candidates[0];if (candidates.length === 0) return null;// Multiple still match — ask for the missing axisreturn { needsMoreInfo: true, candidates };}
If needsMoreInfo, ask the user the discriminating question (“Which color — black or charcoal?”).
3. Confirmation that’s real
A confirmation message that just says “OK, ordering” and proceeds is not consent — it’s an announcement. A confirmation that names the item, total, and destination and waits for an explicit “yes” is consent.
Required content (per Consent & Disclosure):
- What (product, variant, quantity)
- From whom (merchant brand)
- Total cost including tax and shipping
- Where it’s going
Optional but recommended:
- Estimated delivery
- Payment method (“on the Visa ending in 4242”)
- Return policy link
After the confirmation, record the user’s response with a timestamp. This is your evidence in any dispute.
4. Recovery from errors
The most common failure modes in conversational checkout:
| Error | Conversational response |
|---|---|
NotEnoughStockError (409, between confirm and complete-order) |
“Looks like the medium just sold out — they still have a small or a large. Want one of those?” |
CreditCardDeclined (422) |
“The card was declined. Want to try a different one, or use PayPal?” (Do NOT say the issuer’s reason verbatim — it leaks information.) |
No saved address (client-side check before set-shipping-info) |
“What address should this ship to?” |
StoreUnavailable (503) |
“The merchant’s having a hiccup. Try again in a minute?” (Don’t reveal which merchant or technical detail.) |
| Timeout / network error | Retry with the same Idempotency-Key. The user shouldn’t see this. |
See Errors for the full catalog and recovery strategies.
Pre-fetching for responsiveness
To keep the conversation snappy, start the cart preview as soon as the user identifies the product — before they confirm — so you can quote a total without stalling. Don’t parallelize these calls: add-line-item and set-shipping-info both mutate the same cart, and concurrent cart mutations race (last one wins). Chain them serially and let the network round-trips overlap the user’s reading time instead:
// As soon as the user identifies the product, build the cart preview serially —// discard the work if they back out. Both calls mutate the same cart, so they run in order.const cartResp = await callFirmly('/cart/line-items', { add_to_cart_ref, quantity });const addressResp = await callFirmly('/cart/shipping-info', savedAddress);// Now you can quote the user a total in the confirmation message without stalling.
When the user confirms, only complete-order remains — it finalizes the cart the preview built (body: encrypted_card + billing_info), so nothing needs to be re-sent. See Complete Order.
Mid-flow corrections
Users change their minds. Common patterns:
| User says | Your agent does |
|---|---|
| “Make it 2 instead of 1” | PUT /cart/line-items/:id { quantity: 2 } |
| “Actually use the black one” | DELETE /cart/line-items then POST /cart/line-items with the new variant |
| “Add a hat too” | POST /cart/line-items { add_to_cart_ref: <hat>, quantity: 1 } |
| “Ship it to my office instead” | POST /cart/shipping-info { …office_address } (and re-do shipping method) |
| “Cancel that” (before complete-order) | DELETE /cart/line-items, acknowledge to user |
When to bail to a screen
Some moments in conversational checkout are unpleasant in pure text:
- Long address entry (5 fields × possible validation back-and-forth)
- Payment method change (especially adding a new card)
For these, consider handing off to hosted checkout at the awkward moment — text the user a Firmly checkout URL, let them finish on a screen, then re-engage when done.
See Hosted Checkout for the handoff pattern. A hybrid agent (conversational by default, hosted for friction moments) is often the best UX.