Single Product Purchase
This page is the flow-level walkthrough. For a runnable script that executes this exact flow end-to-end, see the Advanced Checkout Guide — both pages describe the same multi-step /api/v2 sequence; this one is annotated for clarity, the other is meant to run.
The five canonical phases
Every Firmly purchase moves through five phases. The single-product flow is the simplest instance.
| Phase | What happens | What you do |
|---|---|---|
| 1. Hosting | Your surface is reachable (your domain in production, or a Firmly UAT environment for testing) | One-time configuration with Firmly |
| 2. Authentication | A Firmly session is established | POST /api/v1/browser-session (or use S2S directly) |
| 3. Discovery | Resolve user intent to a real merchant SKU with live price and stock | POST /api/v1/discovery/search or GET /api/v1/domains-products/{domain}/{handle} |
| 4. Checkout | Cart, address, shipping, payment, place order | A short sequence of calls (see below) |
| 5. Order placement | Order lands in the merchant’s OMS with destination attribution | POST /payment/complete-order |
Sequence diagram
The implementation steps (Phase 4 broken out)
Intent
User tells your agent what they want. Your agent extracts product, options, quantity. No API calls yet.
Discover (Phase 3)
curl -X POST https://api.firmly.work/api/v1/discovery/search \-H "x-firmly-authorization: $TOKEN" \-H "Content-Type: application/json" \-d '{"query": "gift card", "page_size": 5}'
Response contains products[] (across all merchants your App ID can reach). Each product has a domain field identifying its merchant, plus variants[]. Each variant has an add_to_cart_ref — keep it as an opaque handle.
Add to cart (Phase 4 begins)
curl -X POST https://api.firmly.work/api/v2/domains/staging.luma.gift/cart/line-items \-H "x-firmly-authorization: $TOKEN" \-H "Content-Type: application/json" \-d '{"add_to_cart_ref": {...}, "quantity": 1}'
Cart is created implicitly. Response is the full cart object.
Set shipping address (Phase 4)
curl -X POST https://api.firmly.work/api/v2/domains/staging.luma.gift/cart/shipping-info \-H "x-firmly-authorization: $TOKEN" \-H "Content-Type: application/json" \-d '{"first_name": "Agent", "last_name": "Tester","email": "agent@example.com", "phone": "+15551234567","address1": "500 Howard St", "city": "San Francisco","state_or_province": "CA", "country": "US", "postal_code": "94105"}'
Response includes updated shipments[] with shipment_ids.
Optimization tip: if you know the user’s postal code before you have their full address (e.g. from a saved profile), set it earlier via Set Postal Code — Firmly stores it session-wide and the merchant often has shipping rates pre-warmed by the time you set the full address.
Pick a shipping method (Phase 4)
# Read available methods from cart.shipments[].shipping_method_options (set in step 4),# then record the choicecurl -X POST https://api.firmly.work/api/v2/domains/staging.luma.gift/cart/shipment/methods \-H "x-firmly-authorization: $TOKEN" \-H "Content-Type: application/json" \-d '{"shipment_id": "01e232ec-6202-5cb1-25fa-6fe46cc569a9", "shipping_method_id": "method_standard"}'# Optional: for scheduled-delivery / pickup, fetch delivery dates & time slots firstcurl -X POST https://api.firmly.work/api/v2/domains/staging.luma.gift/cart/shipments/get-availability \-H "x-firmly-authorization: $TOKEN" \-H "Content-Type: application/json" \-d '{"shipment_id": "01e232ec-6202-5cb1-25fa-6fe46cc569a9"}'
(If required) Capture merchant consents
Some merchants require the user to agree to terms-of-sale, warranty, or delivery acknowledgments before order placement (this is merchant-required consent, distinct from your agent’s own user-confirmation step). Before complete-order:
GET /api/v2/domains/{domain}/cart/consents— returns an array of required consents withid,html/textto display,required,explicitflags- If the array is non-empty, surface each
textto the user, get explicit agreement, thenPUT /api/v2/domains/{domain}/cart/consentswith{consents: [{id, revoke: false}, ...]}
If you skip this and the merchant required consents, complete-order returns RequiredConsentsNotSigned (412). See Get Consents and Set Consents.
Encrypt payment + complete order (Phase 4 ends)
Get the payment public key, JWE-encrypt the card, then submit. See the Advanced Checkout Guide for the full JWE encryption code.
Note the host change: every call up to this point used https://api.firmly.work; payment calls (get public key and complete-order) go to the dedicated payment host https://cc.firmly.work. Switch hosts for the payment steps to avoid a copy-paste 404.
curl -X POST https://cc.firmly.work/api/v2/payment/domains/staging.luma.gift/complete-order \-H "x-firmly-authorization: $TOKEN" \-H "Idempotency-Key: $(uuidgen)" \-H "Content-Type: application/json" \-d '{"encrypted_card": "eyJh...","billing_info": { ... }}'
Why complete-order and not place-order? The cart was built over the previous calls (add line item → shipping info → shipping method → consents), so complete-order finalizes that existing cart — the body carries only encrypted_card and billing_info (shipping was already set in the earlier steps). Reach for place-order instead only on the one-shot path: it creates a cart and places the order in a single call, and therefore requires an items[] array plus shipping_info in the body because there’s no prior cart to finalize. Verify the request body against Complete Order.
Response includes cart_status: "submitted", cart_id, and urls.order_status_page for customer-facing tracking. The merchant’s native order number comes back in the first-class platform_order_number field (some merchants also echo platform-specific identifiers in custom_properties).
Surface the placed order to the user
Firmly’s scope ends at order placement. After complete-order succeeds, surface the order confirmation to the user using the response:
urls.thank_you_page— the merchant’s own confirmation URL; route the user there to continue their relationship with the merchantcart_idand the merchant’s native order number fromplatform_order_number— for the user’s reference
Anything post-order — order status, tracking, returns, refunds, cancellations — lives in the merchant’s native order management system. The user contacts the merchant directly through the merchant’s existing post-order support channels.
What can go wrong at each step
| Step | Most common failure | Recovery |
|---|---|---|
| Discover | Empty results, StoreUnavailable |
Check domain spelling, confirm merchant is enabled for agentic |
| Add to cart | ProductNotFound, NotEnoughStockError |
Re-fetch product or pick another variant |
| Set shipping | InvalidInputBody |
Validate address fields client-side first |
| Shipping methods | No methods returned | Address may not be deliverable — ask user for an alternative |
| Place order | CreditCardDeclined, NotEnoughStockError (race with another buyer) |
Ask user for different payment method, or re-quote with new stock |
| Post-order status read | 404 (rare, transient) |
Retry with backoff |
See Errors for the full catalog.
What’s in a cart response
Every cart-mutation endpoint returns the full cart object in the response, so your agent doesn’t have to re-fetch. Real shape (anonymized from a QA trace):
{"cart_id": "00000000-0000-0000-0000-000000000000","cart_status": "active","schema_version": "2.0","display_name": "Example Merchant","shop_id": "merchant.example","platform_id": "example_commerce","shop_properties": { "paypal": { "clientId": "...", "merchantId": "..." } },"line_items": [{"line_item_id": "00000000-0000-0000-0000-000000000000","sku": "variant-123","base_sku": "product-123","quantity": 1,"msrp": { "currency": "USD", "value": 25.00, "number": 2500, "symbol": "$" },"price": { "currency": "USD", "value": 25.00, "number": 2500, "symbol": "$" },"line_price": { "currency": "USD", "value": 25.00, "number": 2500, "symbol": "$" },"requires_shipping": true,"image": { "type": "large", "url": "https://cdn.example.com/..." },"platform_line_item_id": "...","description": "Example Product","variant_description": "Default"}],"shipments": [],"addons": { "offers": [], "selections": [] },"sub_total": { "currency": "USD", "value": 25.00, "number": 2500, "symbol": "$" },"shipping_total": { "currency": "USD", "value": 0.00, "number": 0, "symbol": "$" },"tax_total": { "currency": "USD", "value": 0.00, "number": 0, "symbol": "$" },"fees": [{ "description": "Recycle fee", "currency": "USD", "value": 2.00, "number": 200, "symbol": "$" }],"fee_total": { "currency": "USD", "value": 2.00, "number": 200, "symbol": "$" },"addon_total": { "currency": "USD", "value": 0.00, "number": 0, "symbol": "$" },"total": { "currency": "USD", "value": 27.00, "number": 2700, "symbol": "$" },"session": {"requires_login": false,"is_email_registered": false,"is_logged_in": false,"cookies": []},"urls": {},"payment_method_options": [{ "type": "CreditCard", "wallet": "user" },{ "type": "PayPal", "wallet": "paypal" }]}
Field reference
| Field | What it is |
|---|---|
cart_id |
UUID for this cart. Stable across cart-mutation calls within the same session. |
cart_status |
Cart state: active while building, submitted after the order is placed. |
schema_version |
Cart envelope schema version ("2.0"). |
display_name / shop_id / platform_id |
Merchant identity. platform_id is the literal identifier of the merchant’s underlying commerce platform. |
shop_properties |
Merchant-configured properties — most commonly the PayPal client/merchant IDs needed if you’re using PayPal Express. |
line_items[] |
Each item in the cart with quantity, price, image, and a stable line_item_id you use in subsequent PUT/DELETE calls. |
shipments[] |
Populated only after set-shipping-info. Each shipment has its own shipment_id for shipping-method selection. |
addons |
Merchant add-on offers (warranties, gift wrap, etc.) and the user’s selections. |
sub_total / shipping_total / tax_total / addon_total / total |
Money values. All money objects use the shape {currency, value, number, symbol} — value is a decimal, number is the integer in minor units (e.g. cents). |
session |
Session state including login/registration flags and cookies. |
urls |
Filled in after the order is placed with thank_you_page and order_status_page URLs (empty before placement). |
payment_method_options |
Available payment methods for this cart. Each entry has a type and wallet. |
After the order is placed, the same shape comes back with cart_status: "submitted", populated urls, a first-class platform_order_number (the merchant’s native order number), and a custom_properties block with any additional platform-specific identifiers.
Runnable end-to-end
The Advanced Checkout Guide is this exact flow — the multi-step /api/v2 cart — as a complete Node script you can run today. Start there if you want working code, return here for the conceptual walkthrough.