Store, catalog, sign-in, cart, quote, checkout, orders, invoices, account, applications and subscriptions, step by step, with bodies.
The steps below follow one buyer through a custom storefront. Each names the operation, who may call it, the body and the answer. Every call carries X-API-Key and X-Storefront-Host; a step for a buyer adds X-Storefront-Buyer, a step for a guest adds X-Storefront-Guest. Bodies are exactly the schemas of the operation reference; ids are shortened.
1. Read the store
store.get {}, any identity. Answers the published store (see Getting started). Read access, publicPrices, guestCheckout, paymentProvider, fulfillment, currency and acquisition before rendering anything. Call it once per page request; it is not cacheable across buyers because acquisition.smsAvailable and payment capabilities can differ per deployment moment.
session.get {} tells the server what the tokens it holds amount to:
{ "buyerId": "7d2a...", "authenticated": true, "email": "[email protected]", "name": "Ada Lovelace", "confirmed": true }
For a guest: { "buyerId": null, "authenticated": false, "email": null, "name": null, "confirmed": false, "guestId": "5e9b..." }.
2. Browse the catalog
Catalog reads are allowed signed-out on a store with access: "public"; otherwise they need an approved buyer. Prices are null unless the identity may see them.
catalog.categories {} answers the navigation tree the buyer can see, with counts rolled up to parents and no prices:
[
{ "id": "c1a0...", "name": "Drinkware", "handle": "drinkware", "parentId": null, "productCount": 42, "position": 0,
"children": [ { "id": "c1a1...", "name": "Mugs", "handle": "mugs", "parentId": "c1a0...", "productCount": 18, "position": 0, "children": [] } ] }
]
catalog.list pages the products the buyer can see, priced at the buyer's price list:
{ "category": "mugs", "categoryNamespace": "handle", "sort": "price-asc", "page": 1, "pageSize": 24 }
Other fields: query (name or SKU, up to 200 characters), categoryValues (several categories), locationId, searchLocation { lat, lng }, postalCode and country (stock and availability at a pickup or delivery location). Sort by price is refused with 422 VALIDATION while prices are hidden.
{
"products": [
{ "id": "p9f3...", "name": "Ceramic mug, white, 11 oz", "slug": "ceramic-mug-white-11oz", "sku": "MUG-WHT-11",
"price": 1250, "compareAtPrice": null, "stock": 120, "minimumQuantity": 1, "quantityStep": 1,
"image": "https://cdn.kentro.live/.../mug.jpg", "images": ["..."], "category": "Mugs", "featured": true,
"variants": [], "bundle": [], "subscriptionEligible": true, "purchasable": true }
],
"total": 18, "page": 1, "pageSize": 24, "categories": ["Mugs"], "categoryOptions": [ "..." ]
}
catalog.get { "id": "ceramic-mug-white-11oz", "selector": "handle" } answers one product with its variants (each with variantId, attributes, price, stock), options, priceRange, imageDetails, leadTime, stockLevel and tags when the store shows them. selector: "id" takes the product's id; variantId names the variant a URL selected.
catalog.suggest { "query": "mug", "limit": 8 } is the typeahead: { query, products, categories, total, totalCapped, categoryTotal }, limited to 30 calls per 3 seconds per identity.
3. Identify the buyer
Either a guest or a signed-in buyer, per Authentication.
- Guest:
session.guest {}answers{ "guestToken": "..." }whenstore.getsaidguestCheckout: true. Send it asX-Storefront-Guest. - Buyer:
POST https://apis.kentro.live/auth/login/customer { email, password, host }, keep the tokens, sendX-Storefront-Buyer. If the browser also held a guest cart, keep sendingX-Storefront-Guestand run the cart choice before the next cart mutation. - New buyer:
session.signup, then sign in, thenaccount.confirmwhensession.getanswersconfirmed: false.
4. Build the cart
Read it first: cart.get {}.
{
"id": "a7c0...", "currency": "USD", "revision": 0, "ownershipVersion": "c2f7...", "lines": [], "itemCount": 0,
"settings": { "fulfillment": "shipping", "locationId": null, "shipToId": null, "billToId": null, "paymentId": null,
"carrierId": null, "shippingRateId": null, "coupon": "", "useCredits": false, "purchaseOrder": "", "notes": "",
"requestedDate": "", "subscriptionInterval": "none" },
"location": null, "fulfillmentSelection": null, "deliveryEligible": null, "locationNotice": null,
"shippingRates": [], "shippingPending": true, "shippingNotice": null,
"subtotal": 0, "discount": 0, "shipping": 0, "tax": 0, "credits": 0, "total": 0, "charge": 0, "balanceDue": 0,
"processingFee": 0, "quoteFingerprint": null, "pendingAttemptId": null, "quoteReady": false, "checkoutAllowed": false,
"issues": [], "adoption": null, "pricesVisible": true
}
Every mutation answers the whole cart in this shape, so the storefront renders what it gets back and never recomputes totals.
cart.add:
{ "expectedRevision": 0, "ownershipVersion": "c2f7...", "productId": "p9f3...", "variantId": "v110...", "quantity": 12 }
purchase: { instanceId?, packageName? } adds a bundle instance or a named pack size. quantity honors the product's minimumQuantity, maximumQuantity and quantityStep; a quantity outside them is 400 VALIDATION with the message to show.
cart.addMany adds up to 100 lines in one revision (a quick-order form, a reorder):
{ "expectedRevision": 1, "idempotencyKey": "quick-order-2026-11-08-1", "lines": [
{ "productId": "p9f3...", "quantity": 12, "expectedUnitPrice": 1250 },
{ "productId": "p9f4...", "variantId": "v221...", "quantity": 6 } ] }
The answer is the cart plus batch.results, one per line, with the line's outcome and priceChanged when expectedUnitPrice differs from today's price.
cart.set { expectedRevision, lineId, quantity } changes a quantity; cart.remove { expectedRevision, lineId } drops a line.
cart.update sets how the order ships, is billed and is paid. Every field is optional; send what changed:
{
"expectedRevision": 4,
"ownershipVersion": "c2f7...",
"fulfillment": "shipping",
"shipToId": "ad01...",
"billToId": "ad02...",
"paymentId": "pm77...",
"shippingRateId": "rate_ups_ground",
"coupon": "TRADE10",
"useCredits": true,
"creditsDecision": "on",
"purchaseOrder": "PO-4471",
"notes": "Deliver to loading dock B.",
"requestedDate": "2026-11-20"
}
| Field | Notes |
|---|---|
fulfillment | shipping, pickup or delivery, among the store's fulfillment. |
locationId, searchLocation, lineLocations | For pickup or delivery: the location from fulfillment.locations { postalCode, country } or { searchLocation: { lat, lng } }, and per-line locations when lines ship from different zones. |
shipToId, billToId | Address ids from addresses.list (buyers). Guests send cart.contact instead. |
paymentId | A saved method from payments.list, or "new-card" to pay with a card entered at checkout. null clears it. |
carrierId | The buyer's own carrier account from carriers.list, to ship on the buyer's account. |
shippingRateId | One of shippingRates[].id from the last quote; a changed cart resets it. |
coupon, referralCode, useCredits, creditsDecision | Discount code, referral code, and whether store credit applies. |
purchaseOrder, notes, requestedDate | Carried onto the order. |
subscriptionPlanId, selectedFrequency { cycle, cycleUnit } | Make the order a subscription enrollment; subscriptionInterval: "none" clears a legacy interval. |
A guest supplies contact and addresses in one step, cart.contact:
{ "expectedRevision": 2, "ownershipVersion": "c2f7...", "contact": {
"email": "[email protected]",
"shipping": { "name": "Ada Lovelace", "company": "", "line1": "1 Analytical Way", "line2": "", "city": "San Francisco", "region": "CA", "postalCode": "94105", "country": "US", "phone": "" },
"billing": { "name": "Ada Lovelace", "company": "", "line1": "1 Analytical Way", "line2": "", "city": "San Francisco", "region": "CA", "postalCode": "94105", "country": "US", "phone": "" } } }
5. Quote
cart.get { "quote": true } prices the cart with the chosen addresses, method and payment: carrier rates, tax, discounts, credits and the amount to charge now.
{
"revision": 5, "quoteReady": true, "checkoutAllowed": true,
"shippingRates": [ { "id": "rate_ups_ground", "name": "UPS Ground", "amount": 1895, "estimatedDays": "3-5" } ],
"shippingPending": false, "subtotal": 15000, "discount": 1500, "shipping": 1895, "tax": 1187, "credits": 0,
"total": 16582, "charge": 16582, "balanceDue": 0, "processingFee": 0,
"quoteFingerprint": "3f9c…64 hex…", "issues": [], "pendingAttemptId": null
}
What to look at: checkoutAllowed (false while issues lists a problem such as stock that ran out), shippingRates (offer them; cart.update { shippingRateId } picks one and the quote is read again), charge (what is collected now; 0 on payment terms or when a stock-review deposit is not configured), balanceDue (left on the invoice), processingFee (added by the store for card or bank payments where configured). A quote that could not be computed answers 200 with quoteReady: false and the reason in shippingNotice or locationNotice; a cart with no payment method where one is needed answers 409 QUOTE_INCOMPLETE.
pendingAttemptId not null means a checkout is already in flight for this cart: go to step 7 with it.
6. Start checkout
checkout.start places the order from the quote, once. The key makes a retry after a lost response safe.
{
"expectedRevision": 5,
"ownershipVersion": "c2f7...",
"idempotencyKey": "co-a7c0-2026-11-08T14-03",
"quoteFingerprint": "3f9c…64 hex…",
"orderReview": { "evidence": { "...": "..." }, "acceptedDocumentIds": ["terms-2026-01"] },
"bankConsent": true
}
| Field | When |
|---|---|
orderReview | The store's checkoutPolicy requires document acceptance (content.documents). Omit otherwise; a required one missing is 400 VALIDATION. |
bankConsent | Paying by bank debit with a saved bank method. |
opaqueData { dataDescriptor: "COMMON.ACCEPT.INAPP.PAYMENT", dataValue } and cardMetadata { cardLast4 } | paymentId: "new-card" on an Authorize.net store: the nonce from the hosted card form configured by payments.cardConfig {} ({ provider, apiLoginId, clientKey, sandbox }). |
guestAttempt: true | A buyer who signed in mid-checkout finishing the guest cart's checkout. |
The answer is a checkout result:
{ "attemptId": "ck58...", "state": "pending_payment", "redirectUrl": "https://checkout.stripe.com/c/pay/cs_...", "message": "..." }
state | Meaning | Next |
|---|---|---|
prepared | Attempt stored, provider not yet contacted. | Poll checkout.status. |
pending_payment | Payment is with the provider. redirectUrl (hosted page) or clientSecret (Stripe on-page) tells how the buyer pays. | Send the buyer there; when they return, checkout.confirm. |
needs_action | The provider needs the buyer to authenticate (3-D Secure). | Same as above. |
paid, bank_processing | Payment captured or bank debit initiated; the order is being written. | checkout.confirm or poll checkout.status. |
complete | The order exists: orderId is set. | orders.get { id: orderId }. |
needs_attention | Paid, but the store must finish the order by hand; payment says captured, processing, unconfirmed or none. | Show "under review". |
declined | The provider declined. message says what to tell the buyer. | Back to the cart; the reservation is released. |
unknown | The provider never answered. | Keep polling checkout.status; do not start a second checkout. |
An order on payment terms (charge: 0) goes straight to complete.
7. Finish checkout
checkout.status { "attemptId": "ck58..." } answers the same result shape for an attempt. Lost the answer to checkout.start? checkout.status { "idempotencyKey": "co-a7c0-...", "quoteFingerprint": "3f9c..." } finds it. A guest recovering adds guestAttempt: true and the original guest token.
checkout.confirm { "attemptId": "ck58..." } is called when the buyer returns from the provider: it verifies the payment with the provider and completes the order. checkout.cancel { "attemptId" } abandons an attempt only after the provider confirms no payment can complete; otherwise it answers 409 CONFLICT.
cart.checkout is the retired one-step checkout and always answers 409 CONFLICT.
8. Orders
Buyers only (a guest reads the one order it placed with orders.get { id, guestAttempt: true }).
orders.list:
{ "page": 1, "status": "open", "search": "PO-4471", "from": "2026-10-01", "to": "2026-11-08" }
status is open, completed or canceled (together they cover every order once); search matches an order number or PO number, exact or prefix. Answers { orders: [summary], page, pageSize, hasNextPage, total, totalIsCapped }, each summary { id, number, createdAt, currency, status, financialStatus, shippingStatus, deliveryStatus, fulfillment, purchaseOrder, total, balanceDue, paymentTerms }. History includes orders placed before the storefront existed, on the same channel.
orders.get { "id": "o31c..." } answers the order: lines, totals, shipTo, billTo, paymentLabel ("Visa ending in 4242", "Net 30"), paymentMethod, financialStatus, shippingStatus, tracking, shipments[] { carrier, trackingNumber, trackingUrl, shippedAt, deliveredAt, items }, orderReview, notes, storeNote, amountPaid, balanceDue, balanceDueAt.
orders.reorder { "id": "o31c..." } re-quotes an order against today's catalog without writing: per line, whether it is still purchasable, the current price, and the lines cart.addMany would take. The storefront shows the preview, then calls cart.addMany.
9. Invoices
Buyers only. invoices.list { "page": 1, "status": "unpaid", "balance": "overdue", "search": "INV-" } answers { invoices, page, pageSize, hasNextPage, total, totalIsCapped, payment } where payment is online, offline or unavailable (whether the store takes invoice payments through the storefront). Each summary: { id, number, orderId, orderNumber, purchaseOrder, createdAt, invoiceDate, dueDate, currency, status, total, amountPaid, balanceDue, overdue, paymentTerms }.
invoices.get { "id": "inv9..." } adds lines, subtotal, discount, shipping, tax, payments[], and payment: { mode, amount, newCard, methods[] }: the balance the store collects (no partial payments), whether a new card may be used, and the saved methods that can pay it with their fee and total.
Pay with a saved method:
{ "id": "inv9...", "paymentId": "pm77...", "idempotencyKey": "inv9-pay-1", "expectedAmount": 16582, "bankConsent": true }
expectedAmount is payment.methods[].total for the chosen method; a balance that changed since answers 409 CONFLICT. Or open the hosted page for a new card: invoices.payLink { "id": "inv9..." }. Both answer { state, message, attemptId?, redirectUrl?, amount?, link? } with state in ready, redirect, confirming, processing, paid, declined, refused. invoices.paymentStatus { "id", "idempotencyKey"? } is the poll after a redirect; it records a payment that succeeded and closes a stale link (link: open, paid, processing, expired, closed).
10. Account, addresses, saved payments, carriers
Buyers only. Each book is read whole and written with a version.
account.get {} answers { id, version, name, firstName, lastName, email, company, phone, priceList, creditBalance, referralCredit, creditLimit, balanceDue, availableCredit, paymentTerms, taxExempt, notices, minOrderAmount, maxOrderAmount, minOrderQty, maxOrderQty, referralCode }. account.update sends expectedVersion plus at least one of name, firstName, lastName, email, phone:
{ "expectedVersion": "9b1e…64 hex…", "phone": "+1 415 555 0100" }
addresses.list {} answers the address book; addresses.options { "country": "US" } answers { countries, regions } for the form. addresses.save creates (no id; createId makes a retry idempotent) or updates (id and expectedVersion):
{ "label": "Warehouse", "name": "Ada Lovelace", "company": "Analytical Engines Ltd", "line1": "1 Analytical Way", "line2": "",
"city": "San Francisco", "region": "CA", "postalCode": "94105", "country": "US", "phone": "+1 415 555 0100", "isDefault": true,
"type": "shipping", "email": "", "note": "" }
Optional: unitSuit, vatId, carrierId, geoLocation (a geocoder result for delivery zones). addresses.remove { id, expectedVersion }. Both answer the whole book.
payments.list {} answers [{ id, version, type, label, last4, expiry, isDefault, billing, paymentDeadline, shipBeforePayment }] with type in card, bank, terms. Saving a card runs through the store's provider:
- Authorize.net:
payments.cardConfig {}configures the hosted card form;payments.savesends the nonce. - Stripe:
payments.saveanswers aredirectUrlto a hosted setup page.
{ "idempotencyKey": "pm-save-1", "type": "general",
"billing": { "name": "Ada Lovelace", "phone": "", "email": "[email protected]", "address1": "1 Analytical Way", "address2": "", "unitSuit": "", "city": "San Francisco", "stateProvince": "CA", "postalCode": "94105", "country": "US", "vatId": "" },
"opaqueData": { "dataDescriptor": "COMMON.ACCEPT.INAPP.PAYMENT", "dataValue": "..." }, "cardMetadata": { "cardLast4": "4242" } }
type is general, default or backup; an edit sends id and expectedVersion. The answer is a change receipt { state, ... }; payments.status { operationId? } polls it, payments.confirm { operationId } finishes a hosted setup the buyer completed, payments.cancel { operationId } abandons one the buyer left. payments.remove { id, expectedVersion, idempotencyKey? } and payments.default { id, expectedVersion } (answers the list).
carriers.book {} answers { version, entries, options }: the buyer's own carrier accounts and the carriers the store lets buyers ship on. carriers.save:
{ "expectedVersion": "7a3f…64 hex…", "carrier": "5f1f...", "accountNumber": "A1B2C3", "country": "US", "postalCode": "94105", "isDefault": true }
carriers.remove and carriers.default take { id, expectedVersion }; carriers.list {} answers the entries alone.
11. Applications
For stores with applicationEnabled: true: a prospective buyer applies for an account, with attachments and document acceptance, before any sign-in. The applicant is identified by an ownership token the API issues; the storefront server keeps it in the applicant's session.
application.start { "requestId": "<uuid>" }answers the application view withapplicationId,token, the form (fields), the documents to show, andformRevision.application.get { applicationId, token }re-reads it.- For each upload field:
application.upload.permit { applicationId, token, field, name, size, type, sha256 }(typeinapplication/pdf,image/png,image/jpeg; up to 10 MiB) answers{ uploadId, url };application.upload.put { applicationId, token, uploadId, content }sends the file as base64;application.upload.confirm { applicationId, token, uploadId }answers{ uploadId, confirmed }. application.submit:
{ "applicationId": "ap61...", "token": "e4d0…64 hex…", "formRevision": "3",
"fields": { "company": "Analytical Engines Ltd", "taxId": "12-3456789", "website": "https://example.com" },
"attachments": { "resaleCertificate": "up88..." },
"evidence": { "...": "..." }, "displayedDocuments": [ { "id": "terms-2026-01", "revision": "4" } ], "acceptedDocumentIds": ["terms-2026-01"] }
The answer is the result (status pending or approved, and what to tell the applicant). Staff decide in Kentro OS under B2B signup applications; an approved applicant signs in like any buyer.
12. Subscriptions
Buyers only, on stores with subscription plans. subscription.list-plans {} answers [{ id, name, description, type, cycle, cycleUnit, currency, allowedFrequencies, discount, available }]; subscription.plan-details { id } one plan; subscription.active {} the buyer's subscription or null; subscription.history {} its cycles [{ id, status, createdAt, amount, currency, orderId, reference, error }].
Every change takes one body, with expectedRevision from the subscription answered last (0 before the first):
{ "expectedRevision": 0, "requestId": "sub-2026-11-08-1", "planId": "pl20...", "selectedFrequency": { "cycle": 1, "cycleUnit": "month" } }
| Operation | Fields used |
|---|---|
subscription.subscribe | planId, selectedFrequency; the current cart becomes the subscription's items. |
subscription.activate | A paused or pending subscription back to active. |
subscription.pause, subscription.resume, subscription.cancel | expectedRevision and requestId only. |
subscription.skip | Skips the next cycle. |
subscription.update | selectedFrequency, nextCycleDate (ISO date-time). |
subscription.update-items | action: sync-from-cart, add-item (productId, quantity, purchase), update-quantity (lineId, quantity), remove-item (lineId). |
Each answers the subscription: { id, revision, plan, isActive, nextCycleDate, pausedAt, cancelAt, subscribedAt, selectedFrequency, items, pending }. A recurring order needs a reusable payment method on the cart (paymentId a saved card or terms, not new-card and not a bank debit).
