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
- Node 18+ (uses built-in
fetch). - One dependency:
josefor JWE card encryption.
mkdir firmly-standard && cd firmly-standardnpm init -ynpm 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 APIconst PAY_API = 'https://cc.firmly.work'; // payment APIconst DOMAIN = 'staging.luma.gift'; // test merchantconst UA = 'Mozilla/5.0 (compatible)'; // avoids sandbox bot-protection 403sconst 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 productcart = 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 69order 56335 submitted total 74.69
How it works
- Session —
POST /api/v1/browser-sessionexchanges your App ID for a short-livedaccess_token; it rides inx-firmly-authorizationon every later call. - Catalog — list products, open one via its
loc(a ready-made product-by-URL request), and take an available variant’sadd_to_cart_ref. Stock can drift between browse and add, so a409 NotEnoughStockErroron the add means try the next product — not a bug. - Cart — the first add creates the cart; every cart call returns the full cart with merchant-resolved prices.
- Address & shipping — set the address, read the rates, and select one by its
sku. - Payment —
GET /payment/key?format=JWKreturns 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. - Order —
complete-orderfinalizes the existing cart withencrypted_card+billing_info. (place-orderis the one-shot alternative — it builds the cart and pays in a single call, takingitems[]+shipping_info+billing_info.)