A buyer's journey

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": "..." } when store.get said guestCheckout: true. Send it as X-Storefront-Guest.
  • Buyer: POST https://apis.kentro.live/auth/login/customer { email, password, host }, keep the tokens, send X-Storefront-Buyer. If the browser also held a guest cart, keep sending X-Storefront-Guest and run the cart choice before the next cart mutation.
  • New buyer: session.signup, then sign in, then account.confirm when session.get answers confirmed: 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"
}
FieldNotes
fulfillmentshipping, pickup or delivery, among the store's fulfillment.
locationId, searchLocation, lineLocationsFor 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, billToIdAddress ids from addresses.list (buyers). Guests send cart.contact instead.
paymentIdA saved method from payments.list, or "new-card" to pay with a card entered at checkout. null clears it.
carrierIdThe buyer's own carrier account from carriers.list, to ship on the buyer's account.
shippingRateIdOne of shippingRates[].id from the last quote; a changed cart resets it.
coupon, referralCode, useCredits, creditsDecisionDiscount code, referral code, and whether store credit applies.
purchaseOrder, notes, requestedDateCarried 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
}
FieldWhen
orderReviewThe store's checkoutPolicy requires document acceptance (content.documents). Omit otherwise; a required one missing is 400 VALIDATION.
bankConsentPaying 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: trueA 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": "..." }
stateMeaningNext
preparedAttempt stored, provider not yet contacted.Poll checkout.status.
pending_paymentPayment 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_actionThe provider needs the buyer to authenticate (3-D Secure).Same as above.
paid, bank_processingPayment captured or bank debit initiated; the order is being written.checkout.confirm or poll checkout.status.
completeThe order exists: orderId is set.orders.get { id: orderId }.
needs_attentionPaid, but the store must finish the order by hand; payment says captured, processing, unconfirmed or none.Show "under review".
declinedThe provider declined. message says what to tell the buyer.Back to the cart; the reservation is released.
unknownThe 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:

  1. Authorize.net: payments.cardConfig {} configures the hosted card form; payments.save sends the nonce.
  2. Stripe: payments.save answers a redirectUrl to 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.

  1. application.start { "requestId": "<uuid>" } answers the application view with applicationId, token, the form (fields), the documents to show, and formRevision.
  2. application.get { applicationId, token } re-reads it.
  3. For each upload field: application.upload.permit { applicationId, token, field, name, size, type, sha256 } (type in application/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 }.
  4. 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" } }
OperationFields used
subscription.subscribeplanId, selectedFrequency; the current cart becomes the subscription's items.
subscription.activateA paused or pending subscription back to active.
subscription.pause, subscription.resume, subscription.cancelexpectedRevision and requestId only.
subscription.skipSkips the next cycle.
subscription.updateselectedFrequency, nextCycleDate (ISO date-time).
subscription.update-itemsaction: 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).