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

Standard Checkout Guide

A complete, runnable walkthrough of the Standard flow (/api/v1) — session, catalog, cart, address, and a placed order — in about 90 lines of Node against the shared sandbox. It places a real order with a test card (no real money).

​​ Prerequisites


mkdir firmly-standard && cd firmly-standard
npm init -y
npm install jose

Add "type": "module" to package.json so the script can use import.

​​ The full script

Save as standard.mjs and run with FIRMLY_APP_ID=<your app id> node standard.mjs.


// standard.mjs — the Standard (v1) flow, end to end.
import { CompactEncrypt, importJWK } from 'jose';
const APP_ID = process.env.FIRMLY_APP_ID;
const API = 'https://api.firmly.work'; // general API
const PAY_API = 'https://cc.firmly.work'; // payment API
const DOMAIN = 'staging.luma.gift'; // test merchant
const UA = 'Mozilla/5.0 (compatible)'; // avoids sandbox bot-protection 403s
const TEST_CARD = { number: '4111111111111111', name: 'Test Buyer', verification_value: '123', month: '12', year: '2030' };
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' };
const json = r => r.json();
async function ok(r, step) { if (!r.ok) throw new Error(`${step} -> ${r.status}: ${(await r.text()).slice(0, 300)}`); return r; }
// 1. Bootstrap a session — App ID in, access_token out.
const session = await fetch(`${API}/api/v1/browser-session`,
{ method: 'POST', headers: { 'x-firmly-app-id': APP_ID, 'User-Agent': UA } }).then(r => ok(r, 'session')).then(json);
const H = { 'x-firmly-authorization': session.access_token, 'Content-Type': 'application/json', 'User-Agent': UA };
// 2. Pick a product and add it — the cart is created on the first successful add.
// Catalog availability can go stale between browse and add, so a 409
// NotEnoughStockError just means: move on to the next product.
const list = await fetch(`${API}/api/v1/domains-products/${DOMAIN}?page=1&size=100`, { headers: H }).then(r => ok(r, 'catalog')).then(json);
let cart;
for (const item of list.products) {
const pdp = await fetch(item.loc, { headers: H }).then(r => ok(r, 'product')).then(json);
const v = pdp.variants.find(v => v.available); // add_to_cart_ref e.g. { variant_id: 'MH09-S-Blue' }
if (!v) continue;
const res = await fetch(`${API}/api/v1/domains/${DOMAIN}/cart/line-items`,
{ method: 'POST', headers: H, body: JSON.stringify({ add_to_cart_ref: v.add_to_cart_ref, quantity: 1 }) });
if (res.status === 409) continue; // stock drifted since browse — next product
cart = await ok(res, 'add').then(json);
break;
}
if (!cart) throw new Error('no in-stock product on the first catalog page');
console.log('cart', cart.cart_id, 'subtotal', cart.sub_total.value);
// 4. Set the shipping address.
await fetch(`${API}/api/v1/domains/${DOMAIN}/cart/shipping-info`,
{ method: 'POST', headers: H, body: JSON.stringify(ADDRESS) }).then(r => ok(r, 'shipping-info'));
// 5. Read the shipping rates and choose one (methods are keyed by `sku`).
const rates = await fetch(`${API}/api/v1/domains/${DOMAIN}/cart/shipping-rates`, { headers: H }).then(r => ok(r, 'rates')).then(json);
const method = rates[0].sku; // e.g. 'freeshipping_freeshipping'
await fetch(`${API}/api/v1/domains/${DOMAIN}/cart/shipping-method`,
{ method: 'POST', headers: H, body: JSON.stringify({ shipping_method: method }) }).then(r => ok(r, 'shipping-method'));
// 6. Fetch the payment key and JWE-encrypt the card (the raw PAN never leaves your process).
const jwk = await fetch(`${PAY_API}/api/v1/payment/key?format=JWK`, { headers: { 'User-Agent': UA } }).then(r => ok(r, 'key')).then(json);
const encryptedCard = await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(TEST_CARD)))
.setProtectedHeader({ alg: 'RSA-OAEP-256', enc: 'A256GCM', kid: jwk.kid })
.encrypt(await importJWK(jwk, 'RSA-OAEP-256'));
// 7. Complete the order on the existing cart.
const order = await fetch(`${PAY_API}/api/v1/payment/domains/${DOMAIN}/complete-order`,
{ method: 'POST', headers: H, body: JSON.stringify({ encrypted_card: encryptedCard, billing_info: ADDRESS }) }).then(r => ok(r, 'complete-order')).then(json);
console.log('order', order.platform_order_number, order.cart_status, 'total', order.total.value);

​​ What it prints


cart e7e27aaa-36c1-4196-a334-9c57b0001fd9 subtotal 69
order 56335 submitted total 74.69

​​ How it works

  1. Session — POST /api/v1/browser-session exchanges your App ID for a short-lived access_token; it rides in x-firmly-authorization on every later call.
  2. Catalog — list products, open one via its loc (a ready-made product-by-URL request), and take an available variant’s add_to_cart_ref. Stock can drift between browse and add, so a 409 NotEnoughStockError on the add means try the next product — not a bug.
  3. Cart — the first add creates the cart; every cart call returns the full cart with merchant-resolved prices.
  4. Address & shipping — set the address, read the rates, and select one by its sku.
  5. Payment — GET /payment/key?format=JWK returns an RSA public key; the card is encrypted as a compact JWE (RSA-OAEP-256 + A256GCM) so the unencrypted card number never leaves your process.
  6. Order — complete-order finalizes the existing cart with encrypted_card + billing_info. (place-order is the one-shot alternative — it builds the cart and pays in a single call, taking items[] + shipping_info + billing_info.)

​​ Next