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

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-quickstart
npm init -y
npm 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.mjs
import { 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 catalog
const search = await fetch(
`${API}/api/v1/discovery/search`,
{
method: 'POST',
headers: auth,
// discovery/search spans every merchant — scope it to this store with filters.domains
body: JSON.stringify({ query: 'bag', filters: { domains: [DOMAIN] }, page_size: 5 })
}
).then(r => r.json());
// Take the first product and its first *available* variant
const 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 cart
await 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 address
const 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 method
await 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 key
const jwk = await fetch(`${PAY_API}/api/v1/payment/key?format=JWK`, { headers: { 'User-Agent': UA } }).then(r => r.json());
// 8. JWE-encrypt the card
const 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 success
console.log('Cart ID:', order.cart_id); // Firmly's internal reference
console.log('Confirmation URL:', order.urls?.thank_you_page); // merchant's confirmation page
console.log('Merchant ref:', order.platform_order_number); // merchant's order number
console.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

​​ A few things to know

  • This script uses browser session auth. For backend agents, server-to-server is simpler — one static secret, no /browser-session bootstrap.
  • The test card 4111111111111111 only 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-style User-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-order response.