Advanced Checkout Guide
A complete, runnable walkthrough of the Advanced flow (/api/v2) — the richer cart with split shipments, add-ons, delivery scheduling, and ZIP-coverage gating — end-to-end in Node against the sandbox. It places a real order with a test card (no real money).
For solution-specific framing (AI agents, ad surfaces, branded checkout, marketplace), see Solutions after you’ve got the flow running.
Prerequisites
- Node 20 or 22 (LTS)
- An App ID — your
_appId. Contact Firmly to provision a sandbox app and a test merchant domain. - A test merchant domain — for the examples below we use
staging.luma.gift. Substitute the domain Firmly gave you. - One dependency:
jose(for JWE card encryption); everything else (fetch,crypto) is built into Node.
mkdir firmly-quickstart && cd firmly-quickstartnpm init -ynpm install jose
Add "type": "module" to your package.json so the script can use import.
The full script
Save this as agent.mjs:
// agent.mjs — Minimal flow that places a real Firmly order.// Usage: FIRMLY_APP_ID=... node agent.mjsimport { CompactEncrypt, importJWK } from 'jose';import { randomUUID } from 'node:crypto';const APP_ID = process.env.FIRMLY_APP_ID || 'YOUR_APP_ID';const DOMAIN = process.env.FIRMLY_DOMAIN || 'staging.luma.gift';if (APP_ID === 'YOUR_APP_ID') throw new Error('Set FIRMLY_APP_ID to your sandbox App ID first (request one from Firmly): FIRMLY_APP_ID=<uuid> node agent.mjs');const API = 'https://api.firmly.work';const PAY_API = 'https://cc.firmly.work';// Some HTTP clients' default User-Agent is blocked by sandbox bot-protection (403).// A browser-style UA passes — set one on every call.const UA = 'Mozilla/5.0 (compatible; firmly-quickstart)';// A test shipping address. Same address is used for billing.const ADDRESS = {first_name: 'Test', last_name: 'Buyer',email: 'test@example.com',phone: '+15551234567',address1: '500 Howard St',city: 'San Francisco',state_or_province: 'CA',country: 'US',postal_code: '94105'};// A sandbox-safe test card. Only works against test merchants.const TEST_CARD = {number: '4111111111111111',name: 'Test Buyer',verification_value: '123',month: '12',year: '2030'};// 1. Bootstrap a browser session (returns a JWT)const session = await fetch(`${API}/api/v1/browser-session`, {method: 'POST',headers: { 'x-firmly-app-id': APP_ID, 'User-Agent': UA }}).then(r => r.json());const TOKEN = session.access_token;const auth = {'x-firmly-authorization': TOKEN,'Content-Type': 'application/json','User-Agent': UA};// 2. Discover — search the catalogconst search = await fetch(`${API}/api/v1/discovery/search`,{method: 'POST',headers: auth,// discovery/search spans every merchant — scope it to this store with filters.domainsbody: JSON.stringify({ query: 'bag', filters: { domains: [DOMAIN] }, page_size: 5 })}).then(r => r.json());// Take the first product and its first *available* variantconst product = search.products?.find(p => p.domain === DOMAIN);if (!product) throw new Error(`No products matched at ${DOMAIN}`);const variant = product.variants?.find(v => v.available) || product.variants?.[0];const addRef = variant.add_to_cart_ref;// 3. Add to cartawait fetch(`${API}/api/v2/domains/${DOMAIN}/cart/line-items`,{method: 'POST',headers: auth,body: JSON.stringify({ add_to_cart_ref: addRef, quantity: 1 })});// 4. Set shipping addressconst shipped = await fetch(`${API}/api/v2/domains/${DOMAIN}/cart/shipping-info`,{ method: 'POST', headers: auth, body: JSON.stringify(ADDRESS) }).then(r => r.json());const shipmentId = shipped.shipments?.[0]?.shipment_id;// 5. Pick the cheapest shipping method.// Methods are populated inline on the shipment by set-shipping-info —// read them from shipping_method_options (get-availability returns// delivery dates / slots, not methods).// See api-reference/shipment-configuration/shipping-and-fulfillment.const methods = shipped.shipments?.[0]?.shipping_method_options || [];const cheapest = methods.slice().sort((a, b) => a.price.value - b.price.value)[0];// 6. Set the chosen shipping methodawait fetch(`${API}/api/v2/domains/${DOMAIN}/cart/shipment/methods`,{method: 'POST', headers: auth,body: JSON.stringify({shipment_id: shipmentId,shipping_method_id: cheapest.id})});// 7. Get the payment public keyconst jwk = await fetch(`${PAY_API}/api/v1/payment/key?format=JWK`, { headers: { 'User-Agent': UA } }).then(r => r.json());// 8. JWE-encrypt the cardconst publicKey = await importJWK(jwk, 'RSA-OAEP-256');const encryptedCard = await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(TEST_CARD))).setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid: jwk.kid }).encrypt(publicKey);// 9. Complete the order.// The cart was built over steps 3–6 (line item, shipping address, shipping// method), so complete-order finalizes that EXISTING cart — the body carries// only encrypted_card + billing_info. place-order is the one-shot alternative:// it creates the cart AND places the order in a single call, taking an items[]// array plus shipping_info instead. See the API reference for both.const order = await fetch(`${PAY_API}/api/v2/payment/domains/${DOMAIN}/complete-order`,{method: 'POST',headers: {...auth,'Idempotency-Key': randomUUID()},body: JSON.stringify({encrypted_card: encryptedCard,billing_info: ADDRESS})}).then(r => r.json());console.log('Status:', order.cart_status); // "submitted" on successconsole.log('Cart ID:', order.cart_id); // Firmly's internal referenceconsole.log('Confirmation URL:', order.urls?.thank_you_page); // merchant's confirmation pageconsole.log('Merchant ref:', order.platform_order_number); // merchant's order numberconsole.log('Total:', order.total?.symbol + order.total?.value); // value = decimal price; number is smallest-unit (cents)
Run it:
FIRMLY_APP_ID=your_sandbox_app_id node agent.mjs
What each step is doing
Session bootstrap (step 1)
Every Firmly call needs an authenticated session. POST /api/v1/browser-session exchanges your App ID for a short-lived JWT (~1 hour). The JWT goes in the x-firmly-authorization header on subsequent calls. See Authentication for the full auth model.
Discovery (step 2)
POST /api/v1/discovery/search runs a search across every Firmly merchant — that’s the point of discovery. To scope results to one store, pass filters.domains in the request body (as above); each product also carries a domain field you can check client-side.
Add to cart (step 3)
POST /cart/line-items creates a cart implicitly. The response is the full cart object including totals, line items, and an empty shipments[] array.
Shipping address (step 4)
POST /cart/shipping-info sets the destination. The response includes updated shipments[] with shipment_ids. See Shipping & Fulfillment for the shipment model.
Shipping methods (steps 5–6)
For each shipment, read the available methods from its inline shipping_method_options (populated by set-shipping-info), then record your choice. get-availability is a separate, optional call for delivery dates / time slots / pickup locations — not shipping methods — and returns 501 NotImplemented on merchants whose adapters don’t support it.
Payment key (step 7)
GET /payment/key?format=JWK returns an RSA public key. JWE-encrypt the card with this key so the unencrypted PAN (the raw card number) never leaves your process.
JWE encryption (step 8)
The card payload is encrypted with RSA-OAEP-256 + A256GCM and serialized as a compact JWE. The result is a single string passed as encrypted_card.
Complete order (step 9)
POST /payment/complete-order finalizes the cart you built in steps 3–6 — the body is just encrypted_card + billing_info, because the line items and shipping already live on the cart. (The one-shot alternative, place-order, instead creates the cart and places the order in a single call with an items[] array — use it when you have everything up front and haven’t built a cart.) Send an Idempotency-Key UUID with it — but note the core REST endpoints don’t deduplicate on it today, so before any retry, re-read the cart and check cart_status first (see Errors & Conventions).
Where to go from here
- Build with a specific solution → Solutions — agentic, ad, branded, marketplace, publisher, Firmly Connect
- Multi-product / multi-shipment → Shipping & Fulfillment
- Express checkout (Klarna, PayPal) → Klarna Express Checkout or PayPal Express Checkout instead of card encryption
- Error handling → Errors & Conventions for the full catalog and recovery patterns
- Hosted checkout instead → Hosted Checkout — skip steps 4–9 and hand the user off to Firmly’s checkout page
A few things to know
- This script uses browser session auth. For backend agents, server-to-server is simpler — one static secret, no
/browser-sessionbootstrap. - The test card
4111111111111111only works against sandbox / test merchants. Real merchants reject it. - Some HTTP clients’ default User-Agent is blocked by the sandbox’s bot-protection with a
403— the script sets a browser-styleUser-Agent; keep one on your own calls. - Order placement is synchronous on Firmly’s side — you get the merchant’s order identifier back in the
complete-orderresponse.