IntegrationCore Integration

Core Integration

Build a complete storefront with products, cart, checkout, payment and orders. The required path for every Brainerce integration.

This guide is split into 3 files:

  • Part 1 (REQUIRED): This file. Core storefront: products, cart, checkout, payment, orders (read this first)
  • Part 2 (OPTIONAL): Optional Features. Customer accounts, social login, promotions, upsells, downloads
  • Part 3 (REFERENCE): Rules & Reference. Validation rules, error codes, edge cases, decision trees, common mistakes

This file alone is enough to build a working store. Read Part 2 and Part 3 only when needed.

Reading this through a summarizing fetch tool rather than a direct page load? Some of those tools condense long pages, and this one is dense on purpose (exact field names, exact error conditions). If a build using this doc trips on something that reads fine here, re-fetch the raw page directly, or use the Brainerce MCP server's get-integration-guide, get-sdk-docs, and get-critical-rules tools, which return this content without a summarization step in between.

This document explains how to connect any e-commerce website to Brainerce, a product, inventory, and order management platform. An AI coding assistant (Cursor, Lovable, Claude, ChatGPT, Bolt, etc.) can follow this document step-by-step to build a complete e-commerce storefront.

Pre-requirements

  • An existing website or web application with a frontend (HTML/React/Vue/etc.)
  • A Brainerce account with at least one Store created
  • A salesChannelId (starts with vc_) obtained from the Brainerce dashboard sidebar under SELL → Sales Channels. (The SDK still accepts the old name connectionId as a deprecated alias; it warns on every construction and stays supported for backward compatibility. It is not scheduled for removal.)
  • Products must already exist in the Brainerce store (added via the Brainerce dashboard, created through the Admin API / MCP, or imported once from Shopify/WooCommerce with the Migration Tool; no platform app syncs products into Brainerce)

If these requirements are not met, stop the integration and let the user know they must first create a Brainerce account and set up a store with products at https://app.brainerce.com

How it works

Brainerce manages your products, inventory, cart, checkout, payments, and orders. Your website calls the Brainerce API to:

  1. Display products: fetch product data (name, price, images, variants) from Brainerce
  2. Navigation: fetch categories, brands, tags, and search results
  3. Shopping cart: create and manage a cart server-side via API
  4. Checkout: collect customer info, shipping address, and select delivery method
  5. Payment: create a payment intent and process payment via Brainerce's configured payment provider
  6. Orders: orders are created automatically after successful payment
  7. Customer accounts: optional registration, login, order history

Your website handles only the UI. All data and business logic lives in Brainerce.

Field glossary

Brainerce termMeaning
salesChannelIdYour sales channel identifier, starts with vc_. Used in every API call. This is the canonical name; connectionId is the deprecated alias for the same value.
storeIdThe Brainerce store this sales channel belongs to. You do NOT need this, because the salesChannelId resolves it automatically.
sessionTokenA unique token for a guest shopping cart. Store it in localStorage to persist the cart across page loads. It is also that cart's write credential: guest cart writes send it back as the cartToken query parameter (the SDK does this for you). See Cart ownership proof.
cartIdThe server-side cart identifier. Always returned with the cart object.
checkoutIdCreated when the user starts checkout. Used to set addresses, select shipping, and process payment.
variantIdA specific product option combination (e.g., "Large / Red"). Required when adding variable products to cart.
slugURL-friendly product name (e.g., "blue-running-shoes"). Use for product page URLs.
clientSecretThe payment identifier returned by the payment intent API. It is NOT reliably a URL. With the SDK you never read it: client.startPayment() does. See renderArg vs clientSecret.
renderArgclientSdk.renderArg on a payment intent: the URL or argument you hand to the render step (iframe src, redirect target, renderMethod argument). Read it first, with clientSecret as the fallback, whenever you need a URL.
Price fieldsAll prices are returned as strings (e.g., "150.50"). Always use parseFloat() to convert to numbers for display and calculations. Never use parseInt(), which loses decimal places.
SIMPLE productA product with no variants. Use productId only when adding to cart.
VARIABLE productA product with variants (e.g., sizes, colors). You MUST provide both productId and variantId when adding to cart.
KIT productA bundle of real component products sold as one line. Add it with the kit's own productId only, like a SIMPLE product, and never add its components separately (that charges twice and reserves twice). It accepts no variantId and no modifier selections. Its price depends on kitPricingMode: FIXED uses the kit's own basePrice/salePrice, while SUM and SUM_MINUS_PERCENT recompute it from the components on every read, so outside FIXED the stored basePrice is a placeholder and you must use the price returned on the by-slug read. Read kitAvailable for stock (null = unlimited, 0 = not sellable), not inventory: on this sales-channel surface the kit's inventory block is derived from its components and agrees with kitAvailable, but on a storeId storefront it is a placeholder (TRACKED, 0, canPurchase: false) that says nothing, so kitAvailable is the one field that is right in every mode. kitComponents is display only; it comes back on every sales-channel read (list, by-slug, by-id) and on the storeId single reads, never on the storeId list. See Kits.

Security rules

  1. HTTPS only. All API calls MUST use https://. Never use http:// in production. The salesChannelId is transmitted in the URL and must be encrypted in transit.
  2. Never expose customer tokens in URLs. The Authorization: Bearer {token} header must only be sent via headers, never as a URL query parameter. The one deliberate query-string credential is the guest cart's cartToken (see Cart ownership proof): it is a single cart's write credential, not a customer token, and it travels in the query because a same-origin proxy forwards the query and body but drops headers. That is also exactly why it must be redacted from logs.
  3. Sanitize all user input before displaying. Any data returned from the API (product names, error messages) must be escaped before inserting into HTML to prevent XSS attacks. Use your framework's built-in escaping (React does this automatically, vanilla JS must use textContent instead of innerHTML). Note Product.description is HTML, not plain text, so run it through an HTML sanitizer (not text-escaping) that allows its video/embed tags; see Product descriptions can contain video and embeds below.
  4. Never store the customer auth token in localStorage. salesChannelId is safe to store. The guest cart's sessionToken may live in localStorage too (the SDK keeps it there), because the worst case is one basket, but treat it as that cart's write credential: never log it and never put it in a link you share. customerToken is not storable that way: any XSS on the page reads it and the attacker is that customer until it expires. Hold it in a Backend-For-Frontend proxy instead, where the server receives the token and sets an HttpOnly cookie the client cannot read. The raw-fetch snippets below show the browser-only shape for orientation; do not ship it. Never store passwords, credit card numbers, or API secrets.
  5. Calling from a server needs an explicit origin. The sales-channel connection (vc_*) is built for browser-to-API calls, and every /api/vc/* route requires an Origin header — server-side calls do not bypass that check, they are rejected by it with 403 Origin header required, today and not "in the future". Server-side rendering, Route Handlers and background jobs are still supported: pass origin when you construct the client (new BrainerceClient({ salesChannelId, origin })) and derive it from the request rather than hardcoding it, because on LIVE it must match the channel's domain. Do not reach for an admin brainerce_* key instead — that swaps a scoped per-channel credential for a full-access store-wide one. See Origin rules.
  6. Switch the channel to LIVE mode in production. Every Sales Channel has a mode (TEST or LIVE) and a single domain. In TEST mode the Origin is not matched against a domain, so localhost and preview hosts work — but the header must still be present, in TEST exactly as in LIVE. In LIVE mode the channel requires a public https domain with a real TLD, and incoming requests must come from that exact host (or one of its subdomains). Set this from the channel's settings modal in the Brainerce dashboard. Flipping to LIVE will reject any request whose Origin doesn't match.

Cart ownership proof

A cart id alone does not let anyone change a cart. A write to a guest cart must prove the caller holds it by sending that cart's session token (the sessionToken on the cart object) as the cartToken query parameter. Direct callers that cannot use a query string may send the same value in the X-Cart-Session header instead; when both are present the query parameter wins. A cart that belongs to a logged-in customer is proven by that customer's Authorization: Bearer token, and a guest token is irrelevant to it.

Routes that need the proof (sales-channel /api/vc/{salesChannelId} surface; the storeId storefront surface has the same cart, bundle, bump and POST /checkout routes):

  • POST /cart/{cartId}/items, PATCH and DELETE /cart/{cartId}/items/{itemId}
  • DELETE /cart/{cartId} (clear; DELETE /cart/{cartId}/items on the storeId surface)
  • POST and DELETE /cart/{cartId}/coupon
  • POST /cart/{cartId}/recalculate and POST /cart/{cartId}/refresh-snapshots
  • the bundle and order-bump routes under /cart/{cartId}/
  • POST /reservation/extend and POST /reservation/release
  • POST /checkout (creating a checkout from the cart) and the checkout's coupon routes

Reads never need it. GET /cart/{cartId} and GET /cart/session/{sessionToken} carry no proof (the session route is itself the exchange of the token for the cart), and neither do creating a cart, POST /cart/merge, POST /cart/recover and the customer's own GET /customers/me/cart.

With the SDK there is nothing to do. It appends cartToken itself, in one place, on exactly the routes above, URL-encoded and alongside any ?include=. It sends it whenever it holds a guest session token, including while the shopper is signed in and until that cart has been linked or merged into the account (a signed-in shopper whose cart is still the guest cart still needs the proof). It never sends it on creating a cart, the session read, merge, recover, link, the customer's own cart or the admin API-key surface. A backend that does not check ownership ignores it, so it is safe against any backend version.

With raw REST, send it yourself. The brainerceAPI helper in Method B appends it from brainerce_session. If you put a proxy (a Backend-For-Frontend) in front of the API, forward the query string untouched; a proxy that rebuilds the URL from the path alone silently drops the proof.

When the proof is missing or wrong the answer is 403 CART_ACCESS_DENIED, and one message covers every cause (no token, a wrong token, someone else's customer cart), so the response never reveals whether a cart belongs to a customer. It is a 403 and never a 401 on purpose: a storefront proxy clears its auth cookies on any 401, so do not clear the customer session or log the shopper out because of it. It can only happen on a store that enforces the proof: new stores enforce it from the start, and Brainerce moves existing stores over one by one, so send it on every guest cart write now rather than waiting to be switched. Never retry it blindly: the same request fails the same way. The fix is to send the cart's own token. A browser that was handed only a cartId (for example a cart your server created through the /v1 API) holds no token and cannot write to that cart: create the cart in the browser through the sales-channel API, or hand the browser the cart's sessionToken along with the id.

Treat the token as a credential. Never log it, never put it in a link you share, and redact the cartToken query parameter in your CDN, proxy and APM logs, including the Vercel function logs of a Backend-For-Frontend. Read sessionToken from the cart you create (or from GET /cart/session/{token}, or the recovery exchange): do not build on a cart read or write by id returning it, because only create, read-by-session and recovery are guaranteed to. See Error codes.

Vercel function region

If the storefront runs on Vercel, pin the Vercel function region next to the Brainerce API. (This page says "region" elsewhere for commerce regions; this one is where Vercel runs your server code.) Route handlers, server components and a Backend-For-Frontend proxy all run in one Vercel function region, and its default is iad1 (Washington, D.C.). Every call such a function makes to the Brainerce API pays that distance: roughly 300 to 450 ms added to each proxied call, measured from the default region. For a store served from Europe pin Frankfurt:

{ "regions": ["fra1"] }

Put that in vercel.json at the project root, or set Project Settings → Functions → Function Region in the Vercel dashboard, then redeploy. Newer npx create-brainerce-store starters include a vercel.json with fra1; a shop scaffolded before that does not have it, so add it yourself.

Public reads may skip the proxy. Products, categories and search need no customer token, and the salesChannelId is their credential, so the browser can call the API directly (it sends the Origin header itself). Keep the Backend-For-Frontend proxy for what needs the HttpOnly customer cookie. Direct calls also spread the per-IP rate limit across your shoppers instead of funnelling them through one server address, and a customization upload sent straight from the browser never meets the roughly 4.5 MB request body cap that Vercel puts on a proxied one.

Server code that does call the API must send an Origin. A route handler or server component sends none by default, so pass origin when you construct the client, derived from the incoming request so that on a LIVE channel it matches the channel's domain (see rule 5 above and Origin rules). Pinning the function region does not change that.

Do NOT

  1. Build your own cart or pricing logic client-side. Always use the Brainerce Cart API. The server handles pricing, discounts, tax, and inventory. The one thing you may guess locally is what the cart store does: show a new quantity or a removed line before the server answers. That optimistic view is a display guess the next server answer always replaces, and it is never a price, a total or a discount.
  2. Cache product prices or inventory. Always fetch fresh data. Prices and inventory change in real-time. The cart store remembers the previous visit's item count so it can size a loading skeleton; that persisted count is a layout hint, not data, and is never shown as the cart.
  3. Process payments yourself or collect credit card numbers. Always run the payment step through client.startPayment() (or, without the SDK, the Payment Intent API). The card form is always the provider's page, iframe or widget, never your own inputs.
  4. Call completeCheckout before verifying payment status. The order should only be completed after payment is confirmed.
  5. Store customer passwords. Brainerce handles authentication. You only store the token returned after login/register.
  6. Hardcode currency symbols (e.g., "$" or "₪"). Use the currency field from the store response and format with Intl.NumberFormat.
  7. Show raw API error messages to customers for server errors (500) or payment errors. Show user-friendly messages instead. Only show API messages for validation errors (coupon codes, registration).
  8. Assume product availability. Always check the inventory field before showing "Add to Cart".
  9. Skip checkout steps. You must set customer info AND either shipping address or pickup location before payment.
  10. Create a new checkout when payment fails. The existing checkout is still valid. Let the user retry.
  11. Use innerHTML to render API data. Always use textContent or your framework's safe rendering to prevent XSS.
  12. Log customer tokens, emails, or addresses to the browser console in production. Remove all console.log statements that contain user data before going live.
  13. Use a plain <a href> for an internal link (header, logo, "continue shopping", the cart icon). A plain anchor makes the browser load a brand new document, which throws away the cart the page had already loaded and shows the shopper the cart skeleton again on every click. Use your framework's router link (next/link in Next.js; the starter's SmartLink from @/core/lib/navigation). External links, target="_blank" links and file downloads may stay plain anchors.
  14. Change the cart and then fetch it again. Every cart write answers with the whole updated cart, so render that response. A refetch is a second round trip for data you already hold, and one that lands after the shopper's next tap paints an older cart over a newer one. Apply quantity changes optimistically and coalesce rapid taps into at most two requests; the cart store does all of this, so prefer it over hand-written cart state. See Step 3.3b.
  15. Show the empty cart before the first cart answer has arrived, or after a failed load. "Your cart is empty" is only true when the server said so. Show a skeleton while loading (size it from the remembered item count, capped) and an error state with a retry button when loading fails.
  16. Send the shopper to checkout while cart updates are still in flight. Wait for them first: const { ok } = await cart.flush(); and continue only when ok is true. Otherwise checkout is created from a cart that does not yet hold the last tap.
  17. Let a popup, chat bubble, cookie banner or accessibility widget sit on top of the cart quantity controls or the checkout fields. Hide floating widgets on /cart and /checkout. The primary button under the order total is the checkout button, and on phones a sticky checkout bar is expected.
  18. Leave checkout inputs without autocomplete tokens, a name and inputmode for email and phone. See Checkout input attributes.

Multi-language storefronts

If the merchant configured more than one language (the Multi-Language card in the dashboard Settings), the API returns translated name / description / slug etc. on every read, as long as your client sends the active locale.

client.setLocale('he'); // once at app boot
// every subsequent call returns Hebrew where translated, base language otherwise

Or per-call: client.getProductBySlug(slug, { locale: 'he' }).

The whole product graph translates transparently: categories, brands, tags, attribute options, modifier groups and their modifiers, custom-field labels (metafield definitions), product variants, contact form labels. You don't need a separate code path per locale; you only need to apply dir on <html> for RTL languages (he, ar, fa, ur, yi).

Image alt text translates too. Every images[].alt and variant.image.alt (and the cart, checkout, bundle and order-bump equivalents) is already the alt for the active locale, falling back to the default-language alt when the merchant wrote none for it. The shape stays { url, alt }: render alt as is, no per-locale lookup in your image component.

const dir = await client.getStoreDirection(); // 'ltr' | 'rtl'

Two things that bite multi-language storefronts if you only think about display:

  • Search matches translated names only when a locale is active, so call setLocale() before getProducts({ search }).
  • Checkout freezes the locale. Call setLocale() before createCheckout() so the order captures order.locale. That single value drives the language of the confirmation email and the product names shown in order history. Skip it and the order (and its emails) fall back to the store default, regardless of how the buyer was browsing.

See Translations & i18n for the full storefront i18n flow, the list of every translatable entity, per-locale slugs / hreflang, order-email localization, and common pitfalls.

API base URL

All API calls use this pattern:

https://api.brainerce.com/api/vc/{salesChannelId}/{endpoint}

Replace {salesChannelId} with your actual sales channel ID (e.g., vc_abc123xyz). The URL segment is still spelled vc, and that part does not change.

  • All requests use Content-Type: application/json
  • Public endpoints need no authentication, because the salesChannelId in the URL is your credential
  • Customer-authenticated endpoints require the header: Authorization: Bearer {customerToken}
  • Successful responses are HTTP 200 or 201 with a JSON body (a few write routes, such as the analytics beacon, answer 204 with none). Branch on response.ok, not on 200
  • POST /vc/{salesChannelId}/ping is the SDK's heartbeat: it marks the channel CONNECTED and answers { success: true, connectionId, storeName }. The current SDK has no method for it and a storefront never needs to call it
  • All errors return HTTP 4xx/5xx with { "statusCode": number, "code": string, "message": string, "timestamp": string, "path": string }. code is a stable machine-readable identifier; see Rules: Error codes for which endpoints set a specific one and which fall back to a generic BAD_REQUEST / NOT_FOUND.

Two integration methods

You can use either method. Both produce the same result. Both are shown for every task.

npm install brainerce
import { BrainerceClient } from 'brainerce';

const client = new BrainerceClient({
  salesChannelId: 'vc_YOUR_SALES_CHANNEL_ID',
});

Use salesChannelId, not connectionId. connectionId is a deprecated alias kept for backwards compatibility. It still works, but every construction logs a deprecation warning to the console. It is not scheduled for removal; use salesChannelId for new code regardless. The value itself is unchanged: the same vc_* string.

Method B: Direct REST API (works with any language or framework)

const SALES_CHANNEL_ID = 'vc_YOUR_SALES_CHANNEL_ID';
const BASE_URL = `https://api.brainerce.com/api/vc/${SALES_CHANNEL_ID}`;

// Set this once from your router's locale (e.g. "he", "en")
let activeLocale = null;
function setLocale(locale) {
  activeLocale = locale;
}

// Routes that need the guest cart's proof of ownership (see "Cart ownership proof"):
// everything under /cart/{cartId} except /link, creating a checkout, the checkout
// coupon routes and the reservation calls. Never the create, session, merge or
// recover routes.
function needsCartProof(endpoint) {
  const path = endpoint.split('?')[0];
  if (path === '/checkout' || path === '/reservation/extend' || path === '/reservation/release') {
    return true;
  }
  if (/^\/checkout\/[^/]+\/coupon$/.test(path)) return true;
  const m = /^\/cart\/([^/]+)(\/.*)?$/.exec(path);
  if (!m) return false;
  if (['session', 'merge', 'recover'].includes(m[1])) return false;
  return m[2] !== '/link';
}

async function brainerceAPI(method, endpoint, body = null) {
  const options = {
    method,
    headers: { 'Content-Type': 'application/json' },
  };

  // The guest cart's session token, as the cartToken query parameter. Sent
  // whenever you hold one, signed in or not. If you call through your own proxy,
  // that proxy must forward the query string.
  const guestSession = localStorage.getItem('brainerce_session');
  if (guestSession && needsCartProof(endpoint)) {
    endpoint +=
      (endpoint.includes('?') ? '&' : '?') + 'cartToken=' + encodeURIComponent(guestSession);
  }

  // Send locale as Accept-Language header for translated content
  if (activeLocale) {
    options.headers['Accept-Language'] = activeLocale;
  }

  // In memory, NOT localStorage. Any XSS on the page can read localStorage,
  // and this token is the customer until it expires. A module-scoped variable
  // is gone on reload, which is the trade: to survive a reload, put the token
  // in an HttpOnly cookie via a small server route and let the browser attach
  // it. Never write it to localStorage or sessionStorage.
  if (customerToken) {
    options.headers['Authorization'] = `Bearer ${customerToken}`;
  }

  if (body) options.body = JSON.stringify(body);

  const response = await fetch(`${BASE_URL}${endpoint}`, options);

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: 'Unknown error' }));
    if (response.status === 401) {
      customerToken = null;
    }
    // `code` is the stable machine code (CART_NOT_FOUND, CART_ACCESS_DENIED, ...).
    // Branch on it, never on `message`.
    throw {
      statusCode: response.status,
      message: error.message,
      error: error.error,
      code: error.code,
    };
  }

  return response.json();
}

Task list

Required (build these in order):

#TaskWhat it does
1Discover store capabilitiesLearn what features are available (payment, shipping, OAuth, etc.)
2Products, categories & searchDisplay products, navigation, filtering, search
3Shopping cartCart CRUD, coupons, nudges
4Checkout flowCustomer info, addresses, shipping/pickup, order summary
5PaymentPayment intent, Stripe integration, status polling
6Order confirmationSuccess page, order status page from the email link, status display
7Error handlingGlobal error handler, user-friendly messages

Optional enhancements (implement any or all, in any order):

#TaskWhat it does
8Customer accountsRegistration, login, password reset, email verification
9Customer profile & order historyProfile management, saved addresses, past orders
10Social login (OAuth)Google, Facebook, GitHub sign-in
11Discount banners & promotionsPromotional banners, product badges, free shipping bar
12Upsells: bundles, bumps & upgradesCross-sell in cart and checkout
13Inventory countdown & reservationReal-time stock countdown, reservation timer
14Digital product downloadsDownload files after purchase
15Loyalty & rewardsPoints balance + pending points, tiers, redemption, badges
16Referrals & birthday giftsMember share links, referral landing page, birthdays
17Paid loyalty membershipPremium subscription plans on a saved card
18Embeddable loyalty widgetDrop-in iframe for pages outside the storefront
19Gift cardsRedeem a gift card at checkout (a tender, not a discount)

More optional features (back-in-stock alerts, wishlist from SDK 3.16.0, donations, events, marketing tags) are sections of Optional features, not numbered tasks.


REQUIRED TASKS


Task 1: Discover store capabilities

Before building any UI, fetch the store configuration. This tells you what features are available so you build only what's relevant.

Use the SDK method

client.getStoreCapabilities() fetches this. Do not hand-roll a fetch to the route:

import type { StoreCapabilities } from 'brainerce';

// A brand-new store is empty and this call can fail. Catch it and fall back to
// your own defaults rather than blocking the render.
const capabilities: StoreCapabilities | null = await client
  .getStoreCapabilities()
  .catch(() => null);

Call it once at app start and share the result through context or a module-level cache. It is per sales channel, not per product, so a call per page is repeated work.

getStoreInfo() is the nearest other SDK method and it is not a substitute. It returns store identity (name, currency, language, timezone, logo, i18n, SEO tokens) plus the channel's own picture as channelLogo (null when the merchant set none; show channelLogo ?? logo) and a few of the same channel flags (allowedScopes, ordersWriteEnabled, guestCheckoutTracking, requireEmailVerification, requireBirthday, stockAlertsEnabled), and it carries no features block at all. Payment providers, OAuth providers, shipping zones, coupons, checkout custom fields, published content, loyalty and downloadable products live only in the capabilities payload. Read both if you want everything: getStoreInfo() for the identity and SEO fields, /capabilities for the feature switches.

AI coding agents get the same payload from the get-store-capabilities tool on the Brainerce MCP server, which calls this exact route.

REST API

GET /capabilities

Sales-channel mode only. The route is /api/vc/{salesChannelId}/capabilities, which is what client.getStoreCapabilities() calls. A storefront connected by plain storeId has no channel to read it from, so the SDK method throws a 400 BrainerceError there and the raw route answers 404. In that mode build from getStoreInfo() plus the feature-specific reads (getPaymentProviders(), getAvailableOAuthProviders(), and so on) instead. An admin apiKey addresses the store rather than any single channel, so it cannot read this either.

Response

{
  "store": {
    "name": "My Online Store",
    "currency": "ILS",
    "language": "he"
  },
  "connection": {
    "allowedScopes": [
      "products:read",
      "inventory:read",
      "cart:write",
      "checkout:write",
      "customers:auth"
    ],
    "ordersWriteEnabled": false,
    "guestCheckoutTracking": true,
    "requireEmailVerification": false,
    "requireBirthday": false,
    "sandboxPaymentsEnabled": true,
    "reservationStrategy": "ON_PAYMENT",
    "reservationTimeout": 15,
    "lowStockWarning": true,
    "lowStockThreshold": 5
  },
  "features": {
    "paymentProviders": [{ "name": "Stripe Payments", "provider": "stripe" }],
    "oauthProviders": [
      { "provider": "GOOGLE", "isEnabled": true },
      { "provider": "FACEBOOK", "isEnabled": false },
      { "provider": "GITHUB", "isEnabled": false }
    ],
    "oauthRedirectReady": true,
    "hasShippingZones": true,
    "hasDiscountRules": true,
    "hasDownloadableProducts": false,
    "hasCoupons": true,
    "hasCheckoutCustomFields": true,
    "hasGiftCards": false,
    "hasContent": true,
    "hasLoyaltyProgram": false
  }
}

features carries more booleans than are shown here (referrals, birthday rewards, badges, paid membership). Treat an absent key as false rather than assuming the list is closed.

Decision table: what to build based on capabilities

CapabilityValueWhat to do
features.paymentProvidersempty array []Stop checkout integration. Show: "Payment not configured. Set up a payment provider in the Brainerce dashboard."
features.paymentProviders[0].providerany real providerRun the payment step with client.startPayment() (Task 5). Never branch on the provider name
features.paymentProviders[0].provider"sandbox"Test mode: skip real payment, complete checkout directly
connection.sandboxPaymentsEnabledtrueShow a "Test Mode" banner on the site
features.hasCouponstrueShow coupon input on cart page
features.hasCouponsfalseHide coupon input
features.hasGiftCardstrueShow the gift-card field on the checkout page (Optional Integration, Task 19). A per-store switch, not a count — a store that has issued no cards yet still reports true, so build it now rather than waiting for a card to exist
features.hasGiftCardsfalseHide the gift-card field. Note the switch gates issuing; build the redemption UI behind this flag and it appears the day the merchant turns it on
features.hasShippingZonestrueShow shipping address form + shipping method selection in checkout
features.hasShippingZonesfalseSkip shipping address and method, only collect customer info
features.hasDiscountRulestrueImplement discount banners (Task 11)
features.hasDownloadableProductstrueImplement download access after purchase (Task 14)
features.oauthProvidershas items with isEnabled: trueShow social login buttons (Task 10)
features.oauthRedirectReadyfalseStill build the buttons (they hide on redirectReady), and tell the merchant to add the storefront address to the sales channel; oauthRedirectBlockedReason says why (Task 10)
connection.requireEmailVerificationtrueShow email verification step after registration
connection.requireBirthdaytrueMake birthMonth + birthDay required on the registration form (Task 8.1). Enforced on vc_* channel password registration only, never on a storeId storefront, never on OAuth sign-in or guest checkout, and never for existing accounts. Absent on a backend that predates the flag, which means false
connection.lowStockWarningtrueShow "Only X left!" when stock <= lowStockThreshold. ⛔ Pass the merchant's number: getStockStatus() defaults its lowStockThreshold option to 0, so it never says "Low Stock" until you do, and a hardcoded 5 is wrong on every store that chose something else
connection.lowStockWarningfalseShow no low-stock treatment at all, whatever lowStockThreshold says. The merchant turned the urgency messaging off on purpose. Pass 0 to getStockStatus() so nothing is ever flagged low; the item still renders its normal in-stock label
connection.reservationStrategy"ON_CART" or "ON_CHECKOUT"Show the reservation countdown (Task 13), reading reservation off the Cart (ON_CART) or the Checkout (ON_CHECKOUT). On this sales-channel surface both objects always carry reservation; a storeId storefront never receives it, because the hold is per sales channel
connection.reservationStrategy"ON_PAYMENT"The default, so this covers most stores. Stock is only held at payment: reservation still comes back, as { "hasReservation": false, "strategy": "ON_PAYMENT" } with no expiresAt, so gate the countdown on hasReservation, not on the key existing. Degrade to nothing rather than to a zero or a stuck timer. There is no "DISABLED" value: the only three are ON_PAYMENT, ON_CHECKOUT and ON_CART

Save these values in your app state

const storeConfig = {
  storeName: capabilities.store.name,
  currency: capabilities.store.currency,
  language: capabilities.store.language,
  hasCoupons: capabilities.features.hasCoupons,
  hasGiftCards: capabilities.features.hasGiftCards,
  hasShipping: capabilities.features.hasShippingZones,
  hasDownloads: capabilities.features.hasDownloadableProducts,
  hasDiscountRules: capabilities.features.hasDiscountRules,
  paymentProvider: capabilities.features.paymentProviders[0]?.provider || null,
  oauthProviders: capabilities.features.oauthProviders.filter((p) => p.isEnabled),
  lowStockWarning: capabilities.connection.lowStockWarning,
  lowStockThreshold: capabilities.connection.lowStockThreshold,
  requireEmailVerification: capabilities.connection.requireEmailVerification,
  requireBirthday: capabilities.connection.requireBirthday,
  isSandbox: capabilities.connection.sandboxPaymentsEnabled,
  reservationStrategy: capabilities.connection.reservationStrategy,
};

Task 2: Products, categories & search

Step 2.1: Fetch categories for navigation

GET /categories

Response, a tree of categories with nested children:

[
  {
    "id": "cat_1",
    "name": "Shoes",
    "slug": "shoes",
    "children": [
      { "id": "cat_2", "name": "Running Shoes", "slug": "running-shoes", "children": [] }
    ]
  },
  {
    "id": "cat_3",
    "name": "Clothing",
    "slug": "clothing",
    "children": []
  }
]

Use this to build a navigation menu or sidebar. Categories can be nested (parent → children → grandchildren). Each node's slug links its category page: /category/{slug}.

Step 2.1a: Category (collection) page

Category pages are the highest-leverage organic-SEO surface, because they rank for broad "research intent" queries ("running shoes") that individual product pages never capture. getCategoryBySlug returns the landing-page metadata (merchant-authored description + meta, written in the dashboard SEO hub); the products themselves come from getProducts({ categories: [id] }).

GET /categories/slug/{slug}
// app/category/[slug]/page.tsx
const category = await client.getCategoryBySlug(params.slug).catch(() => null);
if (!category) notFound();

const { data: products } = await client.getProducts({ categories: [category.id] });

Response:

{
  "id": "cat_2",
  "name": "Running Shoes",
  "slug": "running-shoes",
  "description": "<p>150–300 words of category copy…</p>",
  "metaDescription": "Shop running shoes…",
  "image": "https://…",
  "breadcrumb": [{ "name": "Shoes", "slug": "shoes" }],
  "productCount": 24
}

SEO rules for this page (see also Part 3 → SEO & Discoverability):

  • Render metaDescription into <meta name="description"> and the description HTML below the product grid (so products stay above the fold). Always sanitize the HTML before rendering.
  • Emit buildCollectionPageJsonLd + buildBreadcrumbJsonLd, never buildProductJsonLd on a listing page.
  • Include category pages in sitemap.xml via getCategorySitemapEntries.

Step 2.2: Fetch brands

GET /brands
[
  { "id": "brand_1", "name": "Nike" },
  { "id": "brand_2", "name": "Adidas" }
]

Step 2.3: Fetch tags (for filtering)

GET /tags
{
  "tags": [
    { "id": "tag_1", "name": "new-arrival" },
    { "id": "tag_2", "name": "bestseller" },
    { "id": "tag_3", "name": "sale" }
  ]
}

Use tags to build filter chips or badges on product cards. Keep the id, because the products list filters by tag id, not name (GET /products?tags=tag_1,tag_2).

Step 2.4: Product listing page

SDK

// "Newest first". For the merchant's curated shelf order, send no sortBy
// at all — that is the default and it is what a listing page should open on.
const response = await client.getProducts({
  page: 1,
  limit: 12,
  sortBy: 'createdAt',
  sortOrder: 'desc',
});

REST API

GET /products?page=1&limit=12&sortBy=createdAt&sortOrder=desc

Every public products read serves active products only: the sales-channel and storeId routes force status=active server-side, so ?status=draft never surfaces a draft there. Drafts are reachable only through the admin route, GET /api/v1/products?status=draft with an API key holding products:read (status is active or draft, omit it for both; anything else is a 400).

Response

{
  "data": [
    {
      "id": "prod_abc123",
      "name": "Blue Running Shoes",
      "slug": "blue-running-shoes",
      "sku": "BRS-001",
      "description": "Comfortable running shoes for daily training.",
      "basePrice": "299.90",
      "salePrice": "249.90",
      "priceMin": "249.90",
      "priceMax": "349.90",
      "priceVaries": true,
      "status": "active",
      "type": "VARIABLE",
      "images": [
        {
          "url": "https://cdn.example.com/shoes-front.jpg",
          "alt": "Blue Running Shoes",
          "position": 0
        },
        { "url": "https://cdn.example.com/shoes-side.jpg", "alt": "Side view", "position": 1 }
      ],
      "categories": [{ "id": "cat_1", "name": "Shoes", "slug": "shoes" }],
      "inventory": {
        "trackingMode": "TRACKED",
        "total": 12,
        "available": 11,
        "inStock": true,
        "canPurchase": true
      },
      "variants": [
        {
          "id": "var_001",
          "name": "Size 42 / Blue",
          "sku": "BRS-001-42-BL",
          "price": "299.90",
          "salePrice": "249.90",
          "attributes": { "size": "42", "color": "Blue" },
          "image": { "url": "https://cdn.example.com/shoes-42-blue.jpg", "alt": "Size 42 Blue" },
          "inventory": {
            "trackingMode": "TRACKED",
            "total": 12,
            "available": 11,
            "inStock": true,
            "canPurchase": true,
            "backorderMode": "NONE"
          }
        },
        {
          "id": "var_002",
          "name": "Size 43 / Blue",
          "sku": "BRS-001-43-BL",
          "price": "299.90",
          "salePrice": null,
          "attributes": { "size": "43", "color": "Blue" },
          "image": null,
          "inventory": {
            "trackingMode": "TRACKED",
            "total": 0,
            "available": 0,
            "inStock": false,
            "canPurchase": false,
            "backorderMode": "NONE"
          }
        }
      ],
      "discount": null,
      "createdAt": "2026-01-15T10:30:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 12,
    "total": 45,
    "totalPages": 4
  }
}

Query parameters

ParameterTypeDefaultMaxDescription
pagenumber1nonePage number (starts at 1)
limitnumber50100Items per page. The default is 50 on this sales-channel surface and 20 on the storeId and admin routes; send it explicitly so the page size does not change when the mode does.
searchstringnonenoneSearch by name, SKU, or description
categoriesstringnonenoneComma-separated category IDs: "cat_1,cat_2"
brandsstringnonenoneComma-separated brand IDs
tagsstringnonenoneComma-separated tag IDs (not names). GET /tags returns the ids: "tag_1,tag_2"
minPricenumbernonenoneMinimum price filter
maxPricenumbernonenoneMaximum price filter
metafieldsobjectnonenoneCustom-field filters (see Step 2.4a). SDK only, because REST sends a JSON-encoded string.
idsstringnone100Return only these products: comma-separated product IDs (SDK: ids: string[], 3.16.0 and newer). More than 100 distinct ids answers 400; an unknown or unpublished id is absent from the answer; an empty ids= returns nothing. Send limit=100 with it. Used to show saved wishlist items; see Wishlist.
sortBystringcuratednone"name", "price" or "createdAt" on this surface. Omit it for the merchant's curated order. Read the note below before you use it.
sortOrderstringascnone"asc" or "desc"
localestringnonenoneIgnored. The SDK never forwards it. Call client.setLocale(locale) once instead: the SDK then sends the Accept-Language header automatically on every request.

sortBy: the default is the curated shelf order, and the accepted set differs by mode

Omit sortBy and you get the merchant's curated order, in every mode. That is menuOrder ascending with unpositioned products last, then newest first as the tiebreak: the arrangement the merchant dragged into place in the dashboard. It is the right default for a category or listing page. Send a sortBy only when the shopper has actually picked a sort from your UI, and offer "Featured" (meaning: send nothing) as the first option so they can get back to it.

The accepted values are not the same on every surface:

SurfaceAccepted sortByUnknown value
Sales-channel, /api/vc/{salesChannelId}/products (this guide)name, price, createdAtSilently ignored, you get the curated order back. No 400, no warning.
Storefront (storeId)name, price, createdAt, updatedAt, menuOrderRejected with 400
Admin (/api/v1/products)name, price, createdAt, updatedAt, menuOrderSilently falls back to createdAt, not to the curated order

Two consequences worth spelling out. Passing the literal string menuOrder on the sales-channel surface is not an error and not a no-op you can detect: it lands in the ignored branch and returns the curated order, which happens to be what you wanted, so the code looks correct and is not portable. And updatedAt simply does not exist here, so a "recently updated" sort built against the /v1 docs quietly degrades to the curated order on a vc_* channel.

price sorts on the stored basePrice column on every surface. For VARIABLE products the price a shopper sees is the lowest variant price, which can differ from basePrice, so a price sort over a variable catalog may not match the numbers on screen exactly.

Step 2.4a: Filter by custom fields (metafields)

Merchants can mark a custom field as filterable in the dashboard. When they do, that field becomes available as a storefront facet, alongside categories, brands, tags, and price.

Only three field types are supported as filters: SELECT, MULTI_SELECT, and BOOLEAN. Unsupported types are silently ignored by the backend.

To discover which fields are filterable for the current store, fetch the metafield definitions and pick the ones with filterable: true:

const { definitions } = await client.getPublicMetafieldDefinitions();
const filterable = definitions.filter((d) => d.filterable);
// Render a checkbox group per SELECT/MULTI_SELECT, a switch per BOOLEAN

Per-site filtering (explicit opt-in). Custom field definitions (and categories / brands / tags) are scoped per sales channel by the merchant. A definition is visible to your site only if the merchant explicitly published it to your connection via the Admin dashboard. If getPublicMetafieldDefinitions() returns an empty array (or fewer entries than expected), the merchant hasn't published those definitions to your site yet. The same gating applies to categories, brands, tags, and to the related arrays on every product response.

Pass selected values to getProducts via the metafields map. Keys are the metafield definition key; values are arrays of accepted values:

// Filter to red OR blue products that are also in stock
const response = await client.getProducts({
  metafields: {
    color: ['red', 'blue'],
    in_stock: ['true'],
  },
});

Semantics: AND across keys, OR within a key. The example above returns products where (color is red OR blue) AND (in_stock is true).

REST: pass a single URL-encoded JSON string as metafields:

GET /products?metafields=%7B%22color%22%3A%5B%22red%22%2C%22blue%22%5D%7D
Facet value counts (GET /metafield-filters)

To render facets with product counts ("Color: red (12) / blue (3)") without one products query per candidate value, fetch the filters endpoint:

const { filters } = await client.getMetafieldFilters();
// Optionally: client.getMetafieldFilters({ locale: 'he' }) — localizes `name`

REST:

GET /metafield-filters
{
  "filters": [
    {
      "id": "def_abc",
      "key": "color",
      "name": "Color",
      "type": "SELECT",
      "enumValues": [
        { "value": "red", "label": "Red" },
        { "value": "blue", "label": "Blue" }
      ],
      "values": [
        { "value": "red", "count": 12 },
        { "value": "blue", "count": 3 }
      ]
    }
  ]
}
  • One entry per definition the merchant marked filterable: true (types SELECT / MULTI_SELECT / BOOLEAN only), subject to the same per-site publishing gate as the products list.
  • count is the number of distinct active products the products list would return for that value (on sales channels: only products published to your connection). MULTI_SELECT arrays are split, so a product holding ["a","b"] counts once under a and once under b. BOOLEAN buckets are always "true" / "false".
  • Declared enumValues always appear (zero-count entries included, so you can render disabled options); stray stored values outside enumValues are reported as-is.
  • Pair each key with getProducts({ metafields: { [key]: [value] } }).

How to render a product card

Security note: When rendering product data in vanilla JS, always use element.textContent = value instead of element.innerHTML = value to prevent XSS attacks. React, Vue, and Svelte handle this automatically.

function renderProductCard(product) {
  // `inventory` is { trackingMode, total, available, inStock, canPurchase, backorderMode? }.
  // `canPurchase` already folds in the tracking mode (UNLIMITED is always
  // purchasable, DISABLED never is), so it is the buy-button gate; `available`
  // is the unit count and only means something when trackingMode is TRACKED.
  // It folds in backorders: a sold-out TRACKED item whose merchant allowed
  // backorders (backorderMode ALLOW or NOTIFY) keeps reading canPurchase: true
  // while `inStock` is false, until its backorder limit is reached. The cart,
  // the reservation and the checkout stock check use the same rule, so the buy
  // button, the cart flag (OUT_OF_STOCK) and the order agree. canPurchase &&
  // !inStock is a purchasable BACKORDER: say "ships when back in stock", never
  // "in stock" or "only 0 left". Items on the default setting (backorderMode
  // NONE) are unchanged: canPurchase equals inStock, and an item at zero is
  // sold out. `available` is real stock and is never negative.
  // On a sales-channel storefront it is what THIS channel may sell: the shared
  // pool minus the units the merchant reserved for other channels, so two
  // channels can show different numbers for the same product.
  // A KIT reports through `kitAvailable` instead (null = unlimited).
  const isOutOfStock =
    product.type === 'KIT' ? product.kitAvailable === 0 : product.inventory?.canPurchase === false;
  const availableStock =
    product.type === 'KIT'
      ? (product.kitAvailable ?? Infinity)
      : product.inventory?.trackingMode === 'TRACKED'
        ? product.inventory.available
        : Infinity;
  // Purchasable with no real stock left: a backorder (the merchant allowed it).
  const isBackorder =
    product.type !== 'KIT' &&
    product.inventory?.canPurchase === true &&
    product.inventory?.inStock === false;
  const isLowStock =
    storeConfig.lowStockWarning &&
    !isOutOfStock &&
    !isBackorder &&
    availableStock <= storeConfig.lowStockThreshold;

  const displayPrice = product.salePrice ?? product.basePrice;
  const hasDiscount = product.salePrice !== null && product.salePrice !== undefined;

  const imageUrl = product.images?.[0]?.url || '/placeholder.png';

  return {
    name: product.name,
    imageUrl,
    price: formatPrice(displayPrice, { currency: storeConfig.currency }),
    originalPrice: hasDiscount
      ? formatPrice(product.basePrice, { currency: storeConfig.currency })
      : null,
    isOutOfStock,
    isBackorder, // show "ships when back in stock" instead of a stock count
    lowStockMessage: isLowStock ? `Only ${availableStock} left!` : null,
    href: `/products/${product.slug}`,
  };
}

Out of stock is not the end of the page. When isOutOfStock is true and the item cannot be backordered, offer stockAlerts.subscribe() in place of the disabled Add to Cart button (integration guide → Optional features → Back-in-stock alerts). It is one email about that one item, not a newsletter signup, so label it "Email me when it's back" and pass the variantId the shopper selected.

Pagination

Use meta.totalPages to render page controls. When user clicks page 2, fetch /products?page=2&limit=12&....

If data is empty, show "No products found."

Step 2.5: Search suggestions (autocomplete)

GET /search/suggestions?q=blue&limit=5

Response:

{
  "products": [
    {
      "id": "prod_abc123",
      "name": "Blue Running Shoes",
      "slug": "blue-running-shoes",
      "image": "https://...",
      "price": "299.90",
      "basePrice": "299.90",
      "salePrice": "249.90",
      "type": "VARIABLE"
    }
  ],
  "categories": [{ "id": "cat_2", "name": "Running Shoes", "productCount": 12 }]
}

Show a dropdown below the search input. Product results link to product pages. Category results filter the product listing.

Default limits: 5 products (max 10), 3 categories (max 5).

Step 2.6: Single product page

GET /products/slug/{slug}

or by ID:

GET /products/{productId}

Slug encoding (manual URL builders): the SDK percent-encodes slugs idempotently (it decodeURIComponents first, then encodeURIComponents), so passing either crème or cr%C3%A8me produces the same wire form. If you build the URL by hand (raw fetch), apply the same normalization: encodeURIComponent(decodeURIComponent(slug)). A double-encoded non-ASCII slug 404s.

Region display pricing: all three reads accept ?regionId=, namely GET /products?regionId=, GET /products/{id}?regionId=, and GET /products/slug/{slug}?regionId=. Each returns the additive displayPrice / displaySalePrice / displayCurrency fields. In the SDK: getProductBySlug(slug, { regionId }).

Returns a single product object (same structure as in the listing response).

How to build the product page

For SIMPLE products:

  1. Show name, description, all images, price
  2. Show "Add to Cart" if inventory.canPurchase is true (for a KIT, if kitAvailable is not 0)
  3. The "Add to Cart" call uses only productId (no variantId)

For VARIABLE products:

Price range: Use product.priceMin / product.priceMax for catalog cards and JSON-LD structured data. product.priceVaries is true when the range should be shown as "₪49 to ₪199". The three fields are populated for VARIABLE products only; a SIMPLE or KIT product carries priceMin: null, priceMax: null, priceVaries: false, so render its single basePrice / salePrice instead. For the currently-selected variant on a product detail page, use getVariantPrice(selectedVariant, product.basePrice).

basePrice / salePrice for VARIABLE products are aggregated from variants. The storefront API returns basePrice = MIN(variants.price) and salePrice = MIN(variants.salePrice WHERE NOT NULL) for VARIABLE products, i.e. the lowest price across variants, and the lowest sale price if at least one variant is on sale. This matches the WooCommerce is_on_sale() semantic and means product.salePrice !== null is a reliable "this product is on sale" signal for both SIMPLE and VARIABLE products. Use the SDK helper getProductPriceInfo(product), which implements this contract for you.

Writing variant prices. Because basePrice is read-only for VARIABLE products, PATCH /v1/products/{id} cannot change their price. Set the price on each variant instead, with client.updateVariant(productId, variantId, { price }), or client.bulkSaveVariants(productId, { variants: [...] }) to update many in one transaction (both require the products:write scope). Variants also accept a costPrice (a unit COGS override for margin analytics, which falls back to the product's costPrice); it is merchant-internal and never appears on public storefront responses. A variant can also carry its own sale window, salePriceStartsAt and salePriceEndsAt (ISO 8601), which overrides the product's window for that variant. Set both bounds together with a salePrice, keep the end after the start, and pass null to clear a bound; in bulkSaveVariants an omitted bound keeps the stored value. See Variants on the changelog for the full route list.

The sale window is enforced everywhere a price is charged or advertised. A salePrice is live only while salePriceStartsAt <= now < salePriceEndsAt (a null bound is open on that side). The end is exclusive. A date-only value is read as a calendar day in the STORE's timezone: salePriceStartsAt: "2026-11-01" opens at 00:00:00.000 that day and salePriceEndsAt: "2026-11-30" closes at 23:59:59.999, so November 30 is a sale day. A full ISO instant (with an offset or Z) is stored exactly as sent. Outside the window the cart, checkout, coupon validation, bundle and order-bump promos, and the admin createOrder price check all use the regular price, and the storefront reads, feeds and storefront MCP do not advertise the sale. A variant's own dates govern its own salePrice; a variant with no dates of its own inherits the product's dates, bound by bound. On storefront reads each variant's salePriceStartsAt / salePriceEndsAt is that EFFECTIVE window (admin reads show the variant's own dates), and the product's top-level salePrice is already null outside its window. The variant salePrice is returned raw, so use getVariantPrice(variant, product.basePrice) or isSalePriceActive(variant) rather than reading variant.salePrice directly. A line added during a sale that has since ended is repriced at checkout: createCheckout refuses it with PRICE_DRIFT until the shopper accepts the new price (client.refreshCartSnapshots(cartId), then retry), so nobody pays an expired sale price, and a line added before a sale started is likewise repriced down to the sale price.

  1. Show name, description, all images, price
  2. Extract variant attributes and build selectors (keys and values are already translated when setLocale() is active):
// Step 1: Get all unique attribute keys and values
function getVariantOptions(variants) {
  const options = {};
  for (const variant of variants) {
    for (const [key, value] of Object.entries(variant.attributes)) {
      if (!options[key]) options[key] = [];
      if (!options[key].includes(value)) options[key].push(value);
    }
  }
  return options;
  // Example: { size: ["42", "43"], color: ["Blue", "Red"] }
}

// Step 2: Find matching variant when user selects all attributes
function findVariant(variants, selectedAttributes) {
  return variants.find((variant) =>
    Object.entries(selectedAttributes).every(([key, value]) => variant.attributes[key] === value)
  );
}

// Step 3: Check if variant is available
function isVariantAvailable(variant) {
  if (!variant) return false;
  if (!variant.inventory) return true;
  return variant.inventory.canPurchase !== false;
}

Rendering attribute selectors with swatch support (getProductSwatches)

The response also includes productAttributeOptions[], the merchant-configured swatch metadata (color hex, dual-color, image, displayType). The SDK ships getProductSwatches(product) which groups it for direct rendering:

import { getProductSwatches } from 'brainerce';

const groups = getProductSwatches(product);
// [
//   { attributeName: "color", displayType: "COLOR_SWATCH", options: [
//     { name: "Full Color", swatchColor: "#1D9435", swatchColor2: null, swatchImageUrl: null },
//     { name: "Black & White", swatchColor: "#000000", swatchColor2: "#FFFFFF", swatchImageUrl: null },
//   ]},
//   { attributeName: "size", displayType: "DEFAULT", options: [
//     { name: "16\" × 20\"" }, { name: "24\" × 32\"" }, { name: "36\" × 48\"" },
//   ]},
// ]

Each group carries its own displayType, so a single product can mix a color swatch (COLOR_SWATCH) and a text-button picker (DEFAULT) on the same page. The four possible values come from the AttributeDisplayType Prisma enum:

displayTypeRender as
DEFAULTPlain text button with the option name. Use this branch as the fallback for any unknown value too.
COLOR_SWATCHRound button filled with swatchColor. If swatchColor2 is set, use a 50/50 linear-gradient (e.g. Black & White).
IMAGE_SWATCHSquare button with swatchImageUrl as <img> thumbnail.
MIXED_SWATCHPer-option hybrid: if swatchImageUrl is set use the image, else fall back to swatchColor (with optional swatchColor2 gradient). Each option in the group can pick its own representation.

Minimal mix renderer (covers all four):

{
  groups.map((group) => (
    <div key={group.attributeName}>
      <label>{group.attributeName}</label>
      <div style={{ display: 'flex', gap: 8 }}>
        {group.options.map((opt) => {
          const colorBg = opt.swatchColor2
            ? `linear-gradient(135deg, ${opt.swatchColor} 50%, ${opt.swatchColor2} 50%)`
            : opt.swatchColor;

          if (group.displayType === 'COLOR_SWATCH' && opt.swatchColor) {
            return (
              <button
                key={opt.name}
                title={opt.name}
                onClick={() => onSelect(group.attributeName, opt.name)}
                style={{ width: 36, height: 36, borderRadius: '50%', background: colorBg }}
              />
            );
          }
          if (group.displayType === 'IMAGE_SWATCH' && opt.swatchImageUrl) {
            return (
              <button key={opt.name} onClick={() => onSelect(group.attributeName, opt.name)}>
                <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
              </button>
            );
          }
          if (group.displayType === 'MIXED_SWATCH') {
            if (opt.swatchImageUrl) {
              return (
                <button key={opt.name} onClick={() => onSelect(group.attributeName, opt.name)}>
                  <img src={opt.swatchImageUrl} alt={opt.name} width={40} height={40} />
                </button>
              );
            }
            if (opt.swatchColor) {
              return (
                <button
                  key={opt.name}
                  title={opt.name}
                  onClick={() => onSelect(group.attributeName, opt.name)}
                  style={{ width: 36, height: 36, borderRadius: '50%', background: colorBg }}
                />
              );
            }
          }
          // DEFAULT or any swatch type missing its data → plain text button.
          return (
            <button key={opt.name} onClick={() => onSelect(group.attributeName, opt.name)}>
              {opt.name}
            </button>
          );
        })}
      </div>
    </div>
  ));
}

After the user picks a value, find the matching ProductVariant yourself (the SDK has no findVariant() helper) and update price/image/stock from it. To grey out unavailable combinations, run the same lookup for each candidate value and check inventory.canPurchase.

import { getVariantOptions, type ProductVariant } from 'brainerce';

// selected = { Color: 'Red', Size: 'M' }, keyed by the attribute name the variants carry
function findVariant(variants: ProductVariant[], selected: Record<string, string>) {
  return variants.find((v) => {
    const options = getVariantOptions(v); // [{ name, value }], platform ids filtered out
    return Object.entries(selected).every(([name, value]) =>
      options.some((o) => o.name === name && o.value === value)
    );
  });
}
  1. Show a selector (dropdown, buttons, or swatches) for each attribute key (e.g., "Size", "Color")
  2. When user selects all attributes → find the matching variant → update the displayed price, image, and stock status
  3. If variant not found or stock = 0 → disable "Add to Cart", show "Out of Stock" or "Unavailable"
  4. If variant has its own image → show it. If image is null → use the product's main image.
  5. The "Add to Cart" call uses both productId AND the selected variant's variantId

Price display logic (applies to both types):

function getDisplayPrice(product, selectedVariant) {
  const item = selectedVariant || product;
  const salePrice = item.salePrice;
  const basePrice = item.price || item.basePrice;

  if (salePrice !== null && salePrice !== undefined) {
    return { current: salePrice, original: basePrice, hasDiscount: true };
  }
  return { current: basePrice, original: null, hasDiscount: false };
}
// If hasDiscount: show current price + crossed-out original price
// If not: show current price only

Step 2.7: Product recommendations (optional)

GET /products/{productId}/recommendations

The response is an object, not an array, and it holds two different kinds of suggestion that are never mixed:

{
  "crossSells": [ /* the merchant's own cross-sells (MANUAL) */ ],
  "upsells":    [ /* premium alternatives (MANUAL) */ ],
  "related":    [ /* related products (MANUAL) */ ],
  "automatic": {
    "kind": "bought_together",
    "items": [ /* cards computed from real purchase history */ ]
  }
}
  • crossSells, upsells, related are manual only: exactly the relations the merchant set on the product. crossSells is not "frequently bought together", so do not title it that. Use a neutral heading such as "Recommended for you".
  • automatic is ONE group computed from the store's real paid orders, and it is optional: it is absent when nothing qualifies (a new or small store has none) and absent when you pass ?type=. kind is "bought_together" (every card was really bought together with this product) or "similar" (same category or tags), never both in one group, and it has at least 2 cards. Render it as its own section after the manual ones and title it from kind: bought_together is "Frequently bought together", similar is "Products you may like". Never merge it into a manual list: a product the merchant listed by hand stays in the manual list and is not repeated in automatic.
  • Hide any section with no items.
  • The store's "Frequently Bought Together" switch (storeInfo.upsell.frequentlyBoughtTogetherEnabled) turns the bought_together group off: the server then sends none, and a storefront should not draw one.
  • Every card carries source (manual, purchase_history or similar) and, when measurement is on, an opaque signed recToken. See Measure recommendations.
  • A card with requiresVariantSelection: true (a variable product the shopper must configure) cannot be added with a bare productId: send the shopper to its product page. A card with targetVariantId is added with that variantId.

Optional query param: ?type=RELATED or ?type=UPSELL or ?type=CROSS_SELL returns only that manual list (and no automatic). Omit it to get everything.

The same object is embedded as product.recommendations on a product read, so the product page needs no extra call.

Step 2.8: Product customization fields (buyer input)

Some products ask buyers to personalize them before adding to cart: engraving text, a photo upload, a color pick, a gift-wrap option. When the merchant assigns "customization fields" to a product in the Brainerce dashboard, the product response includes a customizationFields array. Your job is to render the correct form control per field, validate the buyer's input, then pass the collected values as metadata on the add-to-cart call.

Apply-to-all fields. Merchants can flag a custom field as appliesToAllProducts: true on its MetafieldDefinition. Those fields are treated as present on every product in the account, including ones created after the flag is set. The backend folds them into each product's customizationFields array automatically, so your code does not need to merge or union anything. Render whatever is in product.customizationFields as-is.

Product response shape (new field):

{
  "id": "prod_abc123",
  "name": "Custom Mug",
  "customizationFields": [
    {
      "definitionId": "cdef_01",
      "key": "engraving_text",
      "name": "Engraving text",
      "description": "Up to 20 characters",
      "type": "TEXT",
      "required": true,
      "minLength": 1,
      "maxLength": 20,
      "enumValues": null,
      "defaultValue": null,
      "position": 0
    },
    {
      "definitionId": "cdef_02",
      "key": "frame_color",
      "name": "Frame color",
      "type": "SELECT",
      "required": true,
      "enumValues": [
        { "label": "Black", "value": "black" },
        { "label": "White", "value": "white" },
        { "label": "Gold", "value": "gold", "swatchColor": "#FFD700" }
      ],
      "defaultValue": "black",
      "position": 1
    },
    {
      "definitionId": "cdef_03",
      "key": "upload_photo",
      "name": "Upload your photo",
      "type": "IMAGE",
      "required": true,
      "position": 2
    },
    {
      "definitionId": "cdef_04",
      "key": "addons",
      "name": "Add-ons",
      "type": "MULTI_SELECT",
      "required": false,
      "enumValues": [
        { "label": "Gift wrap", "value": "gift_wrap" },
        { "label": "Rush shipping", "value": "rush_shipping" }
      ],
      "minLength": 0,
      "maxLength": 2,
      "position": 3
    }
  ]
}

If customizationFields is empty or missing, render the product page normally. Otherwise, render one form control per entry in position order:

typeRender asCollected value shape
TEXT<input type="text">string
TEXTAREA<textarea>string
NUMBER<input type="number">number
BOOLEANcheckbox / switchboolean
DATE<input type="date"> (ISO YYYY-MM-DD)string
DATETIME<input type="datetime-local"> (ISO 8601)string
URL<input type="url">string
COLOR<input type="color">string (hex)
SELECT<select> or radio group, options from enumValuesstring (must be in enumValues)
MULTI_SELECTcheckbox group, options from enumValuesstring[] (each in enumValues)
IMAGEfile input + upload to /customization-upload (see below)string (asset URL)
GALLERYmulti-file input + one upload call per filestring[] (asset URLs)
JSONtextarea, parse before submit and validate as JSONstring (serialized JSON)

DATE/DATETIME availability constraints. A field may carry a dateAvailability object (blocked weekdays, blocked specific dates, min/max date, relative bounds, and, for DATETIME only, business hours + time slots). Pass it, together with (await client.getStoreInfo()).timezone, into computeAvailableSlots() / getBusinessHoursForDate() / isDateValueAllowed() (all exported from brainerce) to disable dates/times in your picker and render a slot list. Evaluate in the store's timezone, never the buyer's browser timezone. The backend independently re-validates every submitted value, so this is a client-side UX aid, not the source of enforcement.

leadTimeMinutes, cutoffTime and maxDaysAhead are resolved against the clock rather than against a fixed calendar date, which is what lets a merchant say "we need three hours' notice" or "order by 14:00 for tomorrow" without the setting going stale overnight. They need that clock: pass { timezone } as the last argument of the three helpers above (and now as the 5th argument of isDateValueAllowed()), or they are skipped and your picker offers days the server will refuse. A weekday may also carry more than one businessHours window, so a split day of mornings and evenings is two entries sharing a weekday.

Two traps worth knowing before you build the picker: once businessHours has any entry, every weekday it doesn't list is closed all day (it's an allowlist), and computeAvailableSlots() returns [] whenever slotDurationMinutes is unset even on an open day, so call getBusinessHoursForDate() to tell "closed" from "not slot-based". Submit DATETIME as one ISO-8601 value (2026-08-13T13:00:00+03:00, or 2026-08-13T13:00 to mean store-local); never glue a slot label onto a date, because "2026-08-13T13:00-14:00" is rejected because -14:00 reads as a UTC offset, not a time range. Full contract in Validation rules.

Rendering example (vanilla JS, adapt to your framework):

<form id="customization-form">
  <!-- built dynamically from product.customizationFields -->
</form>

<script>
  const form = document.getElementById('customization-form');
  for (const field of product.customizationFields) {
    const wrapper = document.createElement('div');
    const label = document.createElement('label');
    label.textContent = field.name + (field.required ? ' *' : '');
    wrapper.appendChild(label);

    if (field.type === 'TEXT' || field.type === 'URL') {
      const input = document.createElement('input');
      input.type = field.type === 'URL' ? 'url' : 'text';
      input.name = field.key;
      input.required = field.required;
      if (field.minLength != null) input.minLength = field.minLength;
      if (field.maxLength != null) input.maxLength = field.maxLength;
      if (field.defaultValue) input.value = field.defaultValue;
      wrapper.appendChild(input);
    } else if (field.type === 'SELECT') {
      const select = document.createElement('select');
      select.name = field.key;
      select.required = field.required;
      for (const option of field.enumValues || []) {
        const opt = document.createElement('option');
        opt.value = option.value; // submit the .value (e.g. "gold")
        opt.textContent = option.label; // display the .label (e.g. "Gold")
        if (option.value === field.defaultValue) opt.selected = true;
        select.appendChild(opt);
        // TIP: if option.swatchColor is set, you can render a color swatch alongside
      }
      wrapper.appendChild(select);
    } else if (field.type === 'MULTI_SELECT') {
      for (const option of field.enumValues || []) {
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.name = field.key;
        cb.value = option.value; // submit the .value
        const cbLabel = document.createElement('label');
        cbLabel.appendChild(cb);
        cbLabel.append(' ' + option.label); // display the .label
        wrapper.appendChild(cbLabel);
      }
    } else if (field.type === 'IMAGE' || field.type === 'GALLERY') {
      const file = document.createElement('input');
      file.type = 'file';
      file.accept = 'image/*';
      if (field.type === 'GALLERY') file.multiple = true;
      file.name = field.key;
      wrapper.appendChild(file);
    }

    form.appendChild(wrapper);
  }
</script>

Step 2.8a: Upload buyer-submitted images

For IMAGE / GALLERY fields, the file must be uploaded BEFORE add-to-cart. The SDK exposes uploadCustomizationFile(); the REST endpoint is:

POST /customization-upload?fieldKey=<the field's key>
Content-Type: multipart/form-data
// SDK (recommended) — pass the key of the IMAGE/GALLERY field this file is for
const { url } = await client.uploadCustomizationFile(file, { fieldKey: field.key });

// REST (no auth — the salesChannelId in the URL handles it)
const fd = new FormData();
fd.append('file', file);
const res = await fetch(
  'https://api.brainerce.com/api/vc/{salesChannelId}/customization-upload?fieldKey=' +
    encodeURIComponent(field.key),
  {
    method: 'POST',
    body: fd,
  }
);
const { url } = await res.json();

Rules:

  • image/* only. Server rejects other MIME types with HTTP 400.
  • Max 10 MB per file, 40 megapixels. HEIC is not accepted; convert it to JPEG in the browser before uploading. If your storefront sends uploads through its own server route (the starter store's /api/store proxy does) and is hosted where request bodies are capped (Vercel functions allow about 4.5 MB), a larger photo is refused by your host before it reaches Brainerce, whatever the 10 MB limit here says.
  • Always send fieldKey. uploadCustomizationFile(file, { fieldKey }) needs brainerce SDK 3.10.0 or later; on 3.9.x the second argument is a type error. Print quality is a per-field setting the merchant makes (keepOriginalImage on the field definition). Off by default: the upload is a regular image, converted to WebP and shrunk to 2048px. On: the file is stored at the pixel size and format you sent, so the merchant can print it. The server decides from fieldKey, so an upload that omits it is stored as a regular image even when the merchant switched print quality on, and nothing tells you. A fieldKey that matches no active customer-input IMAGE/GALLERY field is not an error either; a malformed one is HTTP 400.
  • With print quality on, the returned url is a full-size file (up to 10 MB), so preview it with a small <img> rather than assuming it is lightweight. EXIF (including GPS) is removed either way and its rotation is applied to the pixels.
  • 10 uploads / minute per IP (throttle). If you exceed, show a friendly message and let the buyer retry after a short wait.
  • Uploaded files are kept for at least 7 days. If the cart isn't converted to an order within 7 days, the file is automatically deleted. Once the order exists, the file is retained for order-history purposes.

The returned url is what you pass in the add-to-cart metadata. See Step 3.2b below.

Step 2.8b: Display customization labels in the cart and checkout

CartItem and CheckoutLineItem both return a customizations object alongside metadata. Use customizations, not metadata, when rendering line items in your cart drawer or checkout summary. It contains the merchant-defined label, the customer-submitted value, and the field type, so you never need a separate getProduct() call to translate a key into a human-readable name.

// Cart line item — same shape for CheckoutLineItem
interface CartItem {
  metadata?: Record<string, unknown> | null; // raw key→value (for programmatic use)
  customizations?: Record<
    string,
    {
      // resolved for display
      label: string; // merchant-defined field name, e.g. "Frame color"
      value: string | string[]; // customer's choice, e.g. "gold" | ["gift_wrap"]
      type: string; // MetafieldType, e.g. "SELECT"
    }
  >;
  // ...
}

Rendering example:

// Display "Frame color: Gold / Add-ons: Gift wrap, Rush shipping"
function renderCustomizations(item) {
  if (!item.customizations) return '';
  return Object.values(item.customizations)
    .map((c) => `${c.label}: ${Array.isArray(c.value) ? c.value.join(', ') : c.value}`)
    .join(' / ');
}

OrderItem.customizations uses the exact same shape, so your display component works unchanged across cart, checkout, and order-history.

Step 2.8c: Validate before add-to-cart

Validate client-side to give fast feedback, but know the server validates again and rejects invalid input with HTTP 400. Server-enforced rules:

  • required fields must be present and non-empty
  • TEXT / TEXTAREA: respect minLength / maxLength (characters)
  • NUMBER: respect minValue / maxValue
  • SELECT: value must be one of enumValues
  • MULTI_SELECT: value is an array; each entry must be in enumValues; duplicates removed automatically; minLength / maxLength treated as array count bounds
  • IMAGE / GALLERY: values must be URLs returned from /customization-upload on the same store

Step 2.9: Modifier groups (restaurant / build-your-own products)

Modifier groups are merchant-defined option blocks attached to a product. The classic example is "Toppings" on a pizza, where the customer picks 0 to 8 options with the first 3 free, or "Sauce" where exactly one of three options is required. They differ from customization fields (Step 2.8): customization fields are arbitrary buyer input (text, photo, color), while modifier groups are a structured selection with priced options resolved server-side.

If the merchant hasn't attached any groups, product.modifierGroups is empty or missing, so render the product page normally and skip this step.

Product response shape (new field):

{
  "id": "prod_pizza",
  "name": "Family Pizza",
  "modifierGroups": [
    {
      "id": "mg_bread",
      "attachmentId": "pmg_01",
      "name": "Bread type",
      "selectionType": "SINGLE",
      "min": 1,
      "max": 1,
      "freeQuantity": 0,
      "required": true,
      "freeAllocationPolicy": "EXPENSIVE_FREE",
      "modifiers": [
        {
          "id": "m_thin",
          "name": "Thin crust",
          "priceDelta": "0.00",
          "available": true,
          "isDefault": true,
          "position": 0
        },
        {
          "id": "m_thick",
          "name": "Thick crust",
          "priceDelta": "0.00",
          "available": true,
          "isDefault": false,
          "position": 1
        }
      ],
      "defaultModifierIds": ["m_thin"]
    },
    {
      "id": "mg_toppings",
      "attachmentId": "pmg_02",
      "name": "Toppings",
      "selectionType": "MULTIPLE",
      "min": 0,
      "max": 8,
      "freeQuantity": 3,
      "required": false,
      "freeAllocationPolicy": "EXPENSIVE_FREE",
      "modifiers": [
        {
          "id": "m_olive",
          "name": "Olives",
          "priceDelta": "5.00",
          "available": true,
          "isDefault": false,
          "position": 0
        },
        {
          "id": "m_mushroom",
          "name": "Mushrooms",
          "priceDelta": "5.00",
          "available": true,
          "isDefault": false,
          "position": 1
        },
        {
          "id": "m_bacon",
          "name": "Bacon",
          "priceDelta": "7.00",
          "available": true,
          "isDefault": false,
          "position": 2
        },
        {
          "id": "m_egg",
          "name": "Egg",
          "priceDelta": "6.00",
          "available": false,
          "isDefault": false,
          "position": 3
        }
      ],
      "defaultModifierIds": []
    }
  ]
}

Money fields are decimal strings ("5.00", never 5.00), the same convention as unitPrice on cart items. Use parseFloat() for display arithmetic.

Field reference (effective values, which already include any per-variant overrides):

FieldMeaning
selectionTypeSINGLE (radio, max 1) or MULTIPLE (checkbox, bounded by min/max)
min / maxEffective bounds. max: null = unlimited. max: 0 means the group is hidden for this variant
freeQuantityFirst N picks are at no extra cost
requiredCustomer must pick at least 1 (typically min: 1 too)
freeAllocationPolicyEXPENSIVE_FREE (best for customer) / CHEAPEST_FREE (best for merchant) / SELECTION_ORDER
defaultModifierIdsPre-checked options on first render
modifiers[].priceDeltaDecimal string. Negative values are valid downsell modifiers (e.g., "-2.00" for "no bread")
modifiers[].availablefalse = sold out. Render disabled with a "sold out" badge
modifiers[].isDefaultPre-check this modifier on first render (alongside any defaultModifierIds on the group)
modifiers[].referencedProductIdNested combo. See Optional Features "Restaurant features"
modifiers[].excludeFromFreetrue = premium modifier, never allocated as a free selection; customers always pay its priceDelta

If max === 0, the group is hidden for this variant by the merchant, so skip it entirely (do not render, do not include in selections).

Rendering example (vanilla JS, radio for SINGLE, checkbox for MULTIPLE):

<form id="modifiers-form">
  <!-- built dynamically from product.modifierGroups -->
</form>

<script>
  const form = document.getElementById('modifiers-form');
  for (const group of product.modifierGroups || []) {
    if (group.max === 0) continue; // disabled for this variant

    const fieldset = document.createElement('fieldset');
    const legend = document.createElement('legend');
    legend.textContent = group.name + (group.required ? ' *' : '');
    fieldset.appendChild(legend);

    if (group.freeQuantity > 0) {
      const counter = document.createElement('p');
      counter.textContent = `First ${group.freeQuantity} free`;
      fieldset.appendChild(counter);
    }

    const inputType = group.selectionType === 'SINGLE' ? 'radio' : 'checkbox';
    const defaultsSet = new Set(group.defaultModifierIds || []);

    for (const m of group.modifiers) {
      const wrap = document.createElement('label');
      const input = document.createElement('input');
      input.type = inputType;
      input.name = group.id;
      input.value = m.id;
      input.disabled = !m.available;
      input.checked = defaultsSet.has(m.id) || m.isDefault;
      wrap.appendChild(input);
      const label = m.available ? m.name : `${m.name} (sold out)`;
      const delta = parseFloat(m.priceDelta);
      const priceLabel = delta === 0 ? '' : delta > 0 ? ` +${m.priceDelta}` : ` ${m.priceDelta}`;
      wrap.append(' ' + label + priceLabel);
      fieldset.appendChild(wrap);
    }
    form.appendChild(fieldset);
  }
</script>

Step 2.9a: Pass selections on add-to-cart

The cart endpoints accept an optional selections array. The server validates against the effective rules above and computes the final unitPrice (base + paid modifiers, after free-allocation). On success, cart.items[i].modifiers is the snapshot of what was applied.

SDK:

const cart = await client.addToCart(cartId, {
  productId: 'prod_pizza',
  variantId: 'var_large',
  quantity: 1,
  selections: [
    { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
    { modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'] },
  ],
});

REST (sales-channel mode):

await fetch(`https://api.brainerce.com/api/vc/${salesChannelId}/cart/${cartId}/items`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    productId: 'prod_pizza',
    variantId: 'var_large',
    quantity: 1,
    selections: [
      { modifierGroupId: 'mg_bread', modifierIds: ['m_thick'] },
      {
        modifierGroupId: 'mg_toppings',
        modifierIds: ['m_olive', 'm_mushroom', 'm_bacon', 'm_egg'],
      },
    ],
  }),
});

modifierIds is in click-order, which the SELECTION_ORDER free-allocation policy relies on. The server is the source of truth for free-allocation; do not compute it client-side.

Response shape of cart.items[i].modifiers:

{
  "id": "ci_xyz",
  "productId": "prod_pizza",
  "quantity": 1,
  "unitPrice": "98.00",
  "modifiers": [
    { "modifierId": "m_thick", "name": "Thick crust", "priceDelta": "0.00", "freeApplied": false },
    { "modifierId": "m_olive", "name": "Olives", "priceDelta": "5.00", "freeApplied": true },
    { "modifierId": "m_mushroom", "name": "Mushrooms", "priceDelta": "5.00", "freeApplied": true },
    { "modifierId": "m_bacon", "name": "Bacon", "priceDelta": "7.00", "freeApplied": true },
    { "modifierId": "m_egg", "name": "Egg", "priceDelta": "6.00", "freeApplied": false }
  ],
  "modifiersTotal": "6.00"
}

freeApplied: true means this modifier consumed a free slot, so render it with a "free" badge. modifiersTotal is the sum of paid (non-free) deltas.

Step 2.9b: Editing selections after add-to-cart (idempotent)

PATCH /cart/items/:id with a fresh selections array replaces the line's modifiers atomically: the server deletes the old CartItemModifier rows and recreates them inside the same transaction. Omit selections from the body to leave them unchanged (e.g., quantity-only update).

await client.updateCartItem(cartId, itemId, {
  quantity: 1,
  selections: [{ modifierGroupId: 'mg_toppings', modifierIds: ['m_olive', 'm_mushroom'] }],
});

Step 2.9c: When validation fails

Server validation rejects invalid payloads with HTTP 400 and a structured envelope:

{
  "statusCode": 400,
  "code": "MODIFIER_VALIDATION_FAILED",
  "message": "One or more selected options are not valid for this product",
  "details": {
    "errors": [
      {
        "code": "REQUIRED_GROUP_MISSING",
        "message": "Bread type is required",
        "groupId": "mg_bread"
      }
    ]
  },
  "timestamp": "2026-08-23T10:04:11.512Z",
  "path": "/api/stores/store_xyz/cart/cart_abc/items"
}

The issue list is at details.errors. The envelope is rebuilt from a fixed key set, so details is the slot structured context travels in. The top-level message is deliberately generic; render your own copy from details.errors.

The SDK puts the whole body on BrainerceError.details, so from the SDK that array is err.details.details.errors. See Critical Rules → Modifier validation errors for the full code list.

Restaurant features (scheduled availability, nested combos, downsell): see Optional Features "Restaurant / build-your-own products".


Task 3: Shopping cart

The cart is server-side. Your site stores only the sessionToken in localStorage.

localStorage keys used by this integration

KeyWhat it storesWhen to setWhen to clear
brainerce_sessionGuest cart sessionTokenAfter creating a cart (step 3.1)After successful order (Task 6)
brainerce_cart_idCart idAfter creating a cart (step 3.1)After successful order (Task 6)
brainerce_checkout_idCheckout idAfter creating checkout (step 4.1)After successful order (Task 6)

The customer token is deliberately absent from this table. brainerce_cart_id and brainerce_checkout_id are references to a cart or checkout. brainerce_session is more than a reference: it is the guest cart's write credential (see Cart ownership proof), so a script that reads it can change that one cart and start a checkout from it. That reach ends at one guest basket and is why the token may stay in localStorage, but never log it or put it in a shared link. The customer token is a far bigger credential, and localStorage is readable by any script that reaches the page. Hold it in a module-scoped variable, or in an HttpOnly cookie set by your own server route if it must survive a reload. There is no third option that is safe.

What the cart page must show: loading, empty, error

A cart page has three states before it has any lines to draw, and they must stay distinct:

  1. Loading. The first cart answer has not arrived. Show a skeleton, and never the empty-cart message. Size the skeleton from the item count remembered from the last visit (the cart store calls it hintCount), capped at a few rows, because it counts units and not lines.
  2. Error. The load failed (offline, 429, a gateway error). Show an error state with a Retry button. A failed load is not an empty cart and not a spinner that never ends.
  3. Empty. Only when the server answered and the cart has no lines. This is the one place "Your cart is empty" and the link back to products belong.

A storefront that renders the empty state whenever it has no cart object flashes "Your cart is empty" for the length of a network round trip on every page load, and tells a shopper with a full cart that it is empty whenever one request fails.

Use the cart store

With the SDK you do not have to hand-write cart state. createCartStore(client, options) from brainerce (SDK 3.11.0 or later) is a headless store: no markup, no framework dependency, so it suits React, Vue, Svelte and Astro alike. It holds the cart, the loading and error states and the actions, and it implements the rules on this page for you:

  • Explicit statuses: loading, empty, ready, error. empty only after the server said so; error carries retry().
  • Optimistic quantity and removal only. The view is the last server-confirmed cart with your pending changes applied on top. Totals, discounts and tax are always the last confirmed ones (flagged totalsStale until the server answers); the store never computes prices.
  • A serial write queue with coalescing: one write in flight per cart, in the order the shopper acted, and a burst of taps on one line sends at most two requests with the right final quantity. Every write adopts the cart the server returns, with no follow-up read.
  • Every action resolves. add, setQuantity, remove, applyCoupon, removeCoupon, clear, refresh, retry and flush never reject: a failed load lands in loadError and a failed write (already rolled back) in mutationError; clear the latter with dismissMutationError().
  • flush() waits for pending writes, so checkout is only entered with a settled cart, and canCheckout says whether it is safe right now.
  • reset(reason) ('login', 'logout', 'order-complete', 'store-switch') drops the queue and pending changes and loads afresh. Call it after the client reflects the change (setCustomerToken, syncCartOnLogin, ...).
  • loadEnrichment(include) is the cart page's single ?include= request (recommendations, upgrades, bundles), deduped and never automatic.
  • A persisted item-count hint (brainerce:cart-hint:<scope> in localStorage) sized the skeleton; it is never shown as data.

The snapshot fields are status, cart, itemCount, hintCount, isSyncing, totalsStale, pendingCount, canCheckout, loadError, mutationError and epoch.

React (brainerce/react; React 18 or later is an optional peer dependency). Create the store once in a client module, with the browser client (not the server client you construct with origin), and start it at module level so the request begins before React hydrates (start() does nothing on the server):

// lib/cart-store.ts  (a client module)
import { BrainerceClient, createCartStore } from 'brainerce';

export const client = new BrainerceClient({ salesChannelId: 'vc_YOUR_SALES_CHANNEL_ID' });
// `scope` is for the persisted hint: pass the sales channel id.
export const cartStore = createCartStore(client, { scope: 'vc_YOUR_SALES_CHANNEL_ID' });
cartStore.start();
// app/providers.tsx
'use client';
import { CartProvider } from 'brainerce/react';
import { cartStore } from '@/lib/cart-store';

// authKey: the signed-in customer's id, or null when signed out. When it changes
// the provider resets the store ('login' / 'logout'), so call syncCartOnLogin()
// first (see Task 8, Step 8.3).
export function Providers({
  customerId,
  children,
}: {
  customerId: string | null;
  children: React.ReactNode;
}) {
  return (
    <CartProvider store={cartStore} authKey={customerId}>
      {children}
    </CartProvider>
  );
}
// app/cart/cart-page.tsx
'use client';
import { useCart } from 'brainerce/react';
import Link from 'next/link'; // a router link, never <a href="/products">
import { useRouter } from 'next/navigation';

export function CartPage() {
  const cart = useCart();
  const router = useRouter();

  if (cart.status === 'loading') return <CartSkeleton rows={Math.min(cart.hintCount, 3) || 1} />;
  if (cart.status === 'error') return <CartError onRetry={cart.retry} />;
  if (cart.status === 'empty') return <EmptyCart />; // the server said so

  const lines = cart.cart?.items ?? [];
  return (
    <>
      {lines.map((item) => (
        <CartLine
          key={item.id}
          item={item}
          onQuantity={(n) => cart.setQuantity(item.id, n)} // optimistic, coalesced
          onRemove={() => cart.remove(item.id)}
        />
      ))}
      {cart.mutationError && <Notice onDismiss={cart.dismissMutationError} />}
      <Totals cart={cart.cart} stale={cart.totalsStale} />
      <button
        onClick={async () => {
          const { ok } = await cart.flush(); // wait for pending updates first
          if (ok && cart.store.getSnapshot().canCheckout) router.push('/checkout');
        }}
      >
        Checkout
      </button>
    </>
  );
}

useCartItemCount() returns just the number for a header badge and re-renders only when it changes; useCartStore() returns the store for imperative use (store.reset('order-complete') after an order).

Any other framework uses the same store through three calls, getSnapshot(), subscribe(listener) and the actions:

import { createCartStore } from 'brainerce';

const store = createCartStore(client, { scope: 'vc_YOUR_SALES_CHANNEL_ID' });
store.start();

function render() {
  const s = store.getSnapshot(); // a new object only when something changed
  if (s.status === 'loading') return showSkeleton(Math.min(s.hintCount, 3) || 1);
  if (s.status === 'error') return showError(() => store.retry());
  if (s.status === 'empty') return showEmptyCart();
  drawLines(s.cart.items, { onQuantity: (id, n) => store.setQuantity(id, n) });
}
const unsubscribe = store.subscribe(render);
render();

// before checkout
const { ok } = await store.flush();
if (ok) location.assign('/checkout'); // or your router's navigate()

The Steps below (3.1 to 3.6) describe what the store does on the wire. They are the reference for raw REST, and for anything the store does not wrap (bundles, bumps, recalculate). The store does not wrap linkCart / syncCartOnLogin, so keep calling those yourself.

If you are not using the cart store, getSmartCartItemCount() is a synchronous badge count that follows id-based writes on the guest session cart and returns 0 for a logged-in customer: read the count from smartGetCart().itemCount there.

Step 3.1: Create or load cart

async function getOrCreateCart() {
  const sessionToken = localStorage.getItem('brainerce_session');

  if (sessionToken) {
    try {
      const cart = await brainerceAPI('GET', `/cart/session/${sessionToken}`);
      return cart;
    } catch (error) {
      // 404 = the cart is gone (finished, or abandoned more than 30 days ago):
      // fall through and start a new one. Anything else (offline, 429, 502/503/504)
      // says nothing about the cart. Keep the session token and surface the error,
      // or one network blip replaces a cart that is still there.
      if (error.statusCode !== 404) throw error;
    }
  }

  const cart = await brainerceAPI('POST', '/cart');
  localStorage.setItem('brainerce_session', cart.sessionToken);
  localStorage.setItem('brainerce_cart_id', cart.id);
  return cart;
}

A returning shopper's cart comes back. Once a shopper has started checkout, 30 to 60 minutes without activity makes the platform mark the cart ABANDONED so recovery emails can run. That is not deletion: reading the cart by session token, by id, or as the logged-in customer's cart hands it back as ACTIVE (for 30 days after it was marked). So a status other than ACTIVE on a cart you load means it is finished (CONVERTED, MERGED) or too old to restore. Start a new cart then; do not keep asking the same one.

REST API

Create: POST /cart, no body needed. Load by session: GET /cart/session/{sessionToken} Load by ID: GET /cart/{cartId} Load by ID with includes: GET /cart/{cartId}?include=recommendations,upgrades,bundles, which returns the cart with recommendations, upgrades, and bundles fields in a single request instead of separate calls.

Every write to a guest cart below (Steps 3.2 to 3.6, and POST /checkout in Step 4.1) must carry the cart's proof of ownership: the sessionToken from the cart object, as the ?cartToken= query parameter (or the X-Cart-Session header). The reads above need none. The brainerceAPI helper in Method B appends it for you; see Cart ownership proof for the routes and for 403 CART_ACCESS_DENIED.

Cart response

{
  "id": "cart_abc123",
  "sessionToken": "sess_xyz789",
  "customerId": null,
  "status": "ACTIVE",
  "currency": "ILS",
  "subtotal": "0.00",
  "discountAmount": "0.00",
  "ruleDiscountAmount": "0.00",
  "promoDiscountTotal": "0.00",
  "couponCode": null,
  "items": [],
  "itemCount": 0,
  "nudges": [],
  "hasPriceChanges": false,
  "hasUnavailableItems": false,
  "unavailableItemIds": [],
  "expiresAt": null,
  "reservation": { "hasReservation": false, "strategy": "ON_PAYMENT" },
  "createdAt": "2026-04-09T12:00:00.000Z",
  "updatedAt": "2026-04-09T12:00:00.000Z"
}

Display itemCount on the cart icon/badge in the header. reservation is always present on this sales-channel surface (see Task 13; a storeId storefront never gets it), and hasUnavailableItems / hasPriceChanges are the two flags the cart page must act on before checkout (see the cart item structure below).

Step 3.2: Add item to cart

POST /cart/{cartId}/items

For a SIMPLE product:

{
  "productId": "prod_abc123",
  "quantity": 1
}

For a VARIABLE product (MUST include variantId):

{
  "productId": "prod_abc123",
  "variantId": "var_001",
  "quantity": 1
}

Rules:

  • VARIABLE products without variantId → API returns HTTP 400: "Variant selection is required for this product"
  • SIMPLE products: omit variantId entirely (do not send null)
  • KIT products: omit variantId too, and do not send selections — a kit is a fixed recipe, and sending modifier selections returns HTTP 400. Stock is checked across every component, so an out-of-stock component fails the add with INSUFFICIENT_STOCK naming what ran out
  • If same item already in cart → quantity is added to existing
  • Response: full updated cart object

Cart item structure

{
  "id": "item_xyz",
  "productId": "prod_abc123",
  "variantId": "var_001",
  "quantity": 1,
  "unitPrice": "249.90",
  "currentUnitPrice": "249.90",
  "priceChanged": false,
  "priceDelta": "0",
  "priceDirection": "unchanged",
  "isAvailable": true,
  "unavailableReason": null,
  "discountAmount": "0.00",
  "promoDiscountAmount": "0.00",
  "promoSource": null,
  "promoSourceId": null,
  "notes": null,
  "metadata": null,
  "product": {
    "id": "prod_abc123",
    "name": "Blue Running Shoes",
    "sku": "BRS-001",
    "images": [{ "url": "https://...", "alt": "..." }]
  },
  "variant": {
    "id": "var_001",
    "name": "Size 42 / Blue",
    "sku": "BRS-001-42-BL",
    "image": { "url": "https://...", "alt": "..." }
  }
}

Availability is re-checked on every cart read. isAvailable is false when the line can no longer be bought, and unavailableReason says why: PRODUCT_DRAFT, PRODUCT_ARCHIVED, VARIANT_DRAFT, VARIANT_DELETED, INVENTORY_DISABLED, OUT_OF_STOCK or OUT_OF_HOURS (a scheduled-availability product outside its hours). The cart carries the rollup as hasUnavailableItems plus unavailableItemIds. Gate the checkout button on hasUnavailableItems === false yourself: show an inline "no longer available" banner on each flagged row and let the shopper remove it. The server only catches a subset of these later (a deleted variant at checkout creation, a DISABLED product or insufficient stock at completion, as PRODUCT_UNAVAILABLE / INSUFFICIENT_STOCK), so a cart page that ignores the flag sends shoppers into a checkout that fails at the last step.

currentUnitPrice is the live price; unitPrice is the snapshot taken at add-to-cart. When they differ, priceChanged is true and cart.hasPriceChanges lights up. Checkout creation refuses a cart with drifted prices, so surface a "prices changed" notice and call POST /cart/{cartId}/refresh-snapshots (SDK refreshCartSnapshots()) to accept the new prices before creating the checkout.

Step 3.2b: Add-to-cart with customization values

If the product has customizationFields, pass the collected values as metadata. The server validates every field against its definition and rejects the call (HTTP 400) if anything fails.

{
  "productId": "prod_abc123",
  "quantity": 1,
  "metadata": {
    "engraving_text": "Happy Birthday!",
    "frame_color": "Gold",
    "upload_photo": "https://assets.brainerce.com/uploads/store_xxx/products/customizations/abc.jpg",
    "addons": ["Gift wrap"]
  }
}

Key rules:

  • Keys in metadata match the key field of each MetafieldDefinition (not name, not definitionId).
  • Omit optional empty fields. Don't send "" or null unless the buyer actually cleared a default.
  • MULTI_SELECT value is always an array, even with a single selection.
  • IMAGE / GALLERY values must be URLs returned from /customization-upload on this store. Pasting a random external URL will be rejected.

The values are snapshotted onto the resulting order line so fulfillment sees exactly what the buyer submitted, even if the merchant later renames or deletes the field definition.

Step 3.2a: Optional stock pre-check

Add to cart already refuses a line that does not fit (400 INSUFFICIENT_STOCK), so a pre-check is only for UI such as greying out "Add to cart" on a list page. Two SDK calls exist:

  • client.checkStockAvailability([{ productId, variantId?, quantity }]) is POST /api/vc/{salesChannelId}/inventory/check-availability with { "items": [...] } (up to 50 lines, 60 requests a minute per IP, sales-channel mode only, needs the channel's inventory:read or products:read scope; a bad body is a 400, and nothing is reserved). It answers { allAvailable, results: [{ productId, variantId, isAvailable, available, requested, shortfall, trackingMode }] }.
  • client.checkCartStock(cart, selectedItemIds?) runs the same check over a cart's lines (a Cart or its id), optionally only the selected lines of a partial checkout.

available is -1 for an UNLIMITED item and for an item with no inventory row (a KIT never has one, so a kit always answers available; read kitAvailable on the product instead). Never render -1 as a count. On a multi-warehouse store available is the most one warehouse can ship in a single line; with stock protected for other channels it is what this channel may sell, the same number the cart enforces. The answer can be stale a moment later, so it never replaces handling INSUFFICIENT_STOCK on the cart and checkout calls.

Step 3.3: Update item quantity

PATCH /cart/{cartId}/items/{itemId}
{
  "quantity": 3
}
  • quantity is the new total (not a delta). To go from 1→3, send 3.
  • Minimum quantity is 1. To remove, use step 3.4.
  • Rapid taps: do not send one request per tap. See Step 3.3b.

Step 3.3b: Use the cart the server returns (never mutate, then refetch)

Every cart write (Steps 3.2 to 3.6) answers with the whole updated cart: items, totals, discounts, nudges and flags. Render that response.

Do not call GET /cart/{cartId} after a write. The refetch is a second round trip for data you already hold, and a refetch that lands after the shopper's next tap paints an older cart over a newer one. It also spends the per-IP cart-read limit (60 per minute on the sales-channel API, 30 on storeId; see Rate limiting). The same goes for adding to the cart: with SDK 3.11.0 or later smartAddToCart() for a guest who already holds a cart is one request that posts straight to the cached cart, with no read first (the first add of a visit still creates the cart and then adds), and its response is the cart to draw.

Show the change at once, and coalesce taps. On a quantity tap:

  1. Update the line's quantity in your UI immediately. Keep the prices and totals as the server last returned them and mark them stale until the next answer. Never compute a price yourself.
  2. Send the new absolute quantity. Keep at most one write per cart in flight: while one is pending, remember only the latest wanted quantity per line and send that when the answer arrives. A burst of taps is then at most two requests, and the final quantity is right.
  3. If a write fails, roll that line back and show the error, without discarding the shopper's other pending changes. A late answer to an older request must never overwrite a newer wanted quantity.

The cart store does all three. Prefer it over hand-written cart state.

Target the exact line. PATCH and DELETE /cart/{cartId}/items/{itemId} already name the line. The SDK's smartUpdateCartItem(productId, quantity, variantId?) and smartRemoveFromCart(productId, variantId?) find it by product and variant instead, which is the first line when several lines share a product (modifiers, notes, customization), so the wrong line can change. Pass the CartItem.id you are rendering as { itemId } in the last argument:

await client.smartUpdateCartItem(line.productId, 3, line.variantId ?? undefined, {
  itemId: line.id,
});
await client.smartRemoveFromCart(line.productId, line.variantId ?? undefined, { itemId: line.id });

productId is ignored when itemId is given, and the call goes straight to that line with no read first. Without itemId behaviour is unchanged.

Cart error codes. A 404 on a cart route says which thing was missing, in code (BrainerceError.code in SDK 3.11.0 or later, which is error.details.code; undefined when there was no response at all). Branch on the code, never on the message:

StatuscodeMeaningWhat to do
404CART_NOT_FOUNDThe cart id is unknown for this store, or the cart is not ACTIVEThe SDK resolves the cart and retries once: a cart an idle sweep marked ABANDONED is revived in place, a converted or merged one is replaced by a new empty cart and onCartReset fires. On raw REST read GET /cart/session/{token} (that revives an abandoned cart); on a 404 there too, start a new cart.
404CART_ITEM_NOT_FOUNDThe cart has no such lineThe line is already gone: show the current cart. The SDK returns the current cart for a remove and throws for an update.
404PRODUCT_NOT_FOUNDThe product exists in no catalogue this caller can see (also on add)The cart is fine. Tell the shopper the item is unavailable. Not a cart problem and never retried.
404VARIANT_NOT_FOUNDThe product has no such variantSame as above: the cart is fine, the choice is not.
403CART_ACCESS_DENIEDThe guest cart's proof was missing or wrongSend the cart's own cartToken (see Cart ownership proof). Not a login problem: do not clear the customer session. Do not retry unchanged.

A backend older than this change answers every cart 404 with the generic NOT_FOUND or with no code, and the SDK then reads the cart once to tell which it was. A failure that says nothing about the cart (no response, 408, 429, 5xx) is thrown as is, the session is kept, and the write is not replayed (the line may already be in): show an error and let the shopper tap again.

Step 3.4: Remove item

DELETE /cart/{cartId}/items/{itemId}

No body. Response: updated cart.

Step 3.5: Clear all items

DELETE /cart/{cartId}

No body. Response: the emptied cart. The path differs by mode: this sales-channel route is DELETE /cart/{cartId}, while the storeId storefront and admin routes clear with DELETE /cart/{cartId}/items. The SDK's clearCart(cartId) picks the right one.

Step 3.6: Apply coupon

Only if storeConfig.hasCoupons is true.

On the cart page, apply to cart:

POST /cart/{cartId}/coupon
{
  "code": "SAVE20"
}

If invalid/expired → HTTP 400 with user-friendly message. Show this message directly to the user.

Remove from cart:

DELETE /cart/{cartId}/coupon

Step 3.6b: Apply coupon during checkout (preferred)

When the checkout session is already created (the common case, where the coupon field sits on the checkout page), use the checkout-scoped endpoints instead. These apply the coupon to the cart and immediately update the checkout totals in one call:

Apply to checkout:

POST /checkout/{checkoutId}/coupon
{
  "code": "SAVE20"
}

Response: updated Checkout object with recalculated discountAmount and total.

Remove from checkout:

DELETE /checkout/{checkoutId}/coupon

Response: updated Checkout object.

Why this matters: applying a coupon via POST /cart/{cartId}/coupon after a checkout session is already open does not update the checkout totals, so the payment will charge the original amount. Always use the checkout-scoped endpoint when a checkoutId is available.

How to render the cart page

Decide the state first, as in What the cart page must show: loading before empty, and an error that is never mistaken for empty. (With the cart store status and retry() already give you this.)

let state = { status: 'loading', cart: null, hintCount: 0 }; // hintCount: item count remembered from the last visit

async function loadCart() {
  state = { ...state, status: 'loading' };
  renderCart(state);
  try {
    const cart = await getOrCreateCart(); // Step 3.1
    state = { status: cart.items.length === 0 ? 'empty' : 'ready', cart, hintCount: 0 };
  } catch (error) {
    state = { ...state, status: 'error', cart: null };
  }
  renderCart(state);
}

function renderCart({ status, cart, hintCount }) {
  if (status === 'loading') {
    return showCartSkeleton(Math.min(hintCount, 3) || 1); // never the empty state here
  }
  if (status === 'error') {
    return showCartError({ onRetry: loadCart }); // a failed load is not an empty cart
  }
  if (status === 'empty') {
    return showEmptyCart(); // the server answered: "Your cart is empty" + link to products
  }

  for (const item of cart.items) {
    const imageUrl =
      item.variant?.image?.url || item.product?.images?.[0]?.url || '/placeholder.png';

    renderCartItem({
      id: item.id,
      name: item.product.name,
      variantName: item.variant?.name || null,
      image: imageUrl,
      unitPrice: formatPrice(item.unitPrice, { currency: cart.currency }),
      quantity: item.quantity,
      lineTotal: formatPrice(parseFloat(item.unitPrice) * item.quantity, {
        currency: cart.currency,
      }),
      unavailable: item.isAvailable === false ? item.unavailableReason : null,
      promoLabel:
        item.promoSource === 'BUNDLE'
          ? 'Bundle deal'
          : item.promoSource === 'ORDER_BUMP'
            ? 'Added offer'
            : null,
    });
  }

  // Totals. `discountAmount` is already the COMBINED discount (automatic rules
  // + coupon + bundle/bump promos, capped at the subtotal), so the cart total
  // is subtotal minus that one number. `ruleDiscountAmount` and
  // `promoDiscountTotal` are breakdown lines inside it, never added on top,
  // and the coupon's own share is whatever is left once those two come out.
  const subtotal = parseFloat(cart.subtotal);
  const totalDiscount = parseFloat(cart.discountAmount);
  const autoDiscount = parseFloat(cart.ruleDiscountAmount || '0');
  const promoDiscount = parseFloat(cart.promoDiscountTotal || '0');
  const couponDiscount = Math.max(0, totalDiscount - autoDiscount - promoDiscount);
  const total = subtotal - totalDiscount;

  renderTotals({
    subtotal: formatPrice(cart.subtotal, { currency: cart.currency }),
    couponDiscount:
      couponDiscount > 0 ? formatPrice(couponDiscount, { currency: cart.currency }) : null,
    autoDiscount:
      autoDiscount > 0 ? formatPrice(cart.ruleDiscountAmount, { currency: cart.currency }) : null,
    couponCode: cart.couponCode,
    total: formatPrice(total, { currency: cart.currency }),
  });

  // Nudges (e.g., "Add ₪30 more for free shipping!")
  if (cart.nudges && cart.nudges.length > 0) {
    for (const nudge of cart.nudges) {
      showNudgeBanner(nudge.text);
    }
  }

  // Block checkout while any line is unavailable (see Step 3.2).
  setCheckoutEnabled(!cart.hasUnavailableItems);
}

Abandoned-checkout recovery (automatic)

When a shopper abandons a checkout, Brainerce can email them a "Complete your purchase" link. That link points at Brainerce, which validates a secure token, reactivates the cart, and redirects the shopper to your store's cart page as /cart?brainerce_cart=<cartId>#brainerce_recover=<code>: the cart id in the query string, and a one-time code in the URL fragment (the part after #). A browser never sends the fragment to any server, so it stays out of access logs, CDN logs and Referer headers, and it also means server-side rendering cannot see it: the exchange below happens in the browser.

With the SDK you don't handle either part. The SDK reads both when the client is constructed and removes them from the address bar at once (history.replaceState; every other query parameter, the rest of the fragment and the history state are kept), so the one-time code does not linger in the URL, the history entry or a shared link. On the first cart read it trades the code for the cart and its session token with POST /cart/recover and adopts that cart. A lost response is retried with the same attempt id, a transient failure keeps the code pending instead of showing an empty cart, and an unknown or expired code, or the storeId surface (which has no recover route), falls back to reading the cart by the id from the query. Just make sure your cart route renders the active cart on mount through smartGetCart() (or the cart store) and you're done.

⛔ Do not read ?brainerce_cart from the URL yourself. It is already gone once the SDK client exists, so code that reads it after construction sees nothing and the recovered cart is silently lost. (getOrCreateSessionCart() is internal to the SDK: call smartGetCart().)

Without the SDK, do the exchange yourself, in the browser, before anything else rewrites the URL:

// 1. Read both parts of the link, then remove them from the address bar at once.
const url = new URL(location.href);
const cartId = url.searchParams.get('brainerce_cart');
const code = new URLSearchParams(url.hash.slice(1)).get('brainerce_recover');
url.searchParams.delete('brainerce_cart');
url.hash = '';
history.replaceState(history.state, '', url);

// 2. Trade the one-time code for the cart (it includes the sessionToken).
let cart = null;
if (code) {
  const attemptId = crypto.randomUUID(); // generate ONCE and reuse it on a retry
  try {
    cart = await brainerceAPI('POST', '/cart/recover', { code, attemptId });
    localStorage.setItem('brainerce_session', cart.sessionToken);
    localStorage.setItem('brainerce_cart_id', cart.id);
  } catch (error) {
    // Every refusal is the same 404 (unknown, expired, already used by another
    // attempt, other store): fall back to the id from the query below.
    // Anything else is transient: retry with the SAME code and attemptId.
    if (error.statusCode !== 404) throw error;
  }
}
if (!cart)
  cart = await brainerceAPI('GET', `/cart/${cartId ?? localStorage.getItem('brainerce_cart_id')}`);

The code is single use and valid for 10 minutes. Repeating the same code with the same attemptId returns the same cart for 120 seconds, so a response lost on the network can be retried; a different attemptId after a successful redemption is a 404. The route is throttled to 10 requests per minute and is not on the storeId surface.


Task 4: Checkout flow

Checkout is multi-step. Complete steps in order.

Step 4.1: Create checkout from cart

POST /checkout
{
  "cartId": "cart_abc123"
}

For a guest cart this call carries the cart's proof of ownership like every other cart write: POST /checkout?cartToken=<sessionToken> (the SDK and the brainerceAPI helper add it). Before navigating here from the cart page, wait for pending cart updates (await cart.flush(), continue only when ok), or the checkout is created from a cart that does not yet hold the shopper's last change.

Save the returned id:

const checkout = await brainerceAPI('POST', '/checkout', { cartId });
localStorage.setItem('brainerce_checkout_id', checkout.id);

Multi-region stores (optional): if the store uses regions, pass a regionId to associate the checkout with one (the region must belong to the store; an unknown region returns 400). It is recorded for reporting and payment-provider scoping. FX-at-checkout: when the region currency differs from the store base and its payment provider can settle that currency (presentment-enabled, which means Stripe today), the buyer is charged in the region currency and the response carries a presentment overlay with the charged amounts; render presentment.total / presentment.currency when present. Otherwise the checkout is charged in the store base currency (safe fallback). See Regions.

const checkout = await brainerceAPI('POST', '/checkout', { cartId, regionId });

// Show what the buyer will actually be charged:
const shown = checkout.presentment
  ? { amount: checkout.presentment.total, currency: checkout.presentment.currency }
  : { amount: checkout.total, currency: checkout.currency };

Resolving the buyer's region (multi-currency / geo-IP): extract the country from your edge runtime (Cloudflare CF-IPCountry, Vercel request.geo?.country, Fastly client-geo-country) and resolve it with one round trip. Pair with getProducts({ regionId }) to show the buyer's currency. Brainerce never derives the country from the request IP server-side because the storefront server is what reaches the backend (not the end-customer).

const country = headers.get('cf-ipcountry') ?? 'US';
const { region, matched } = await brainerceAPI('GET', `/regions/auto?country=${country}`);
// matched=true → country was in a region's list; false → fell back to default

const { data: products } = await brainerceAPI('GET', `/products?regionId=${region.id}`);
// products[0].displayPrice / displayCurrency populated by the FX overlay

SDK equivalent, same one round trip:

const { region, matched, country } = await client.getAutoRegion(countryFromEdge);
// getAutoRegion(country?: string): Promise<AutoRegionResponse>
// region is null when nothing matched and the store has no default region.
const { data: products } = await client.getProducts({ regionId: region?.id });

If you already hold the region list, client.detectRegion(country, regions) does the same match locally with no network call at all.

Tax preview (non-binding): for PDP / PLP / cart, GET /tax/estimate?country=&subtotal= returns the tax portion at the Standard rate for the buyer's country. The authoritative tax still runs at checkout against the full shipping address.

const { estimatedTax, rate, currency, note } = await brainerceAPI(
  'GET',
  `/tax/estimate?country=IT&subtotal=100`
);
// { appliesTax: true, rate: 22, estimatedTax: 22, currency: 'EUR',
//   note: 'Estimate — final tax calculated at checkout…' }

SDK equivalent:

const estimate = await client.estimateTax({ country: 'IT', subtotal: 100, regionId });
// estimateTax(params: { country?: string; subtotal: number; regionId?: string }): Promise<TaxEstimateResponse>
// regionId is optional: pass it if you know the buyer's region, so the preview follows
// that region's tax-inclusive setting (a region can override the store's pricesIncludeTax)
if (estimate.appliesTax) {
  // Render with an explicit "Estimate" affordance. Never present it as the total.
}

appliesTax comes back false when tax is switched off, when you passed no country, or when no active rate covers the one you passed, and untaxedReason says which: 'disabled', 'no_country' or 'no_rate'. It is absent when tax applies, and on older backends, so read it defensively. Take the country from the same edge header you use for getAutoRegion, and remember this is a preview: the binding tax is calculated at checkout against the full shipping address, so the number can change.

The response also carries rates[], one entry per rate behind the estimate, because more than one tax can apply to the same sale; rate and rateName summarise the set and rates[] itemises it. The estimate only sees country-level rates, so it can never show a stacked provincial figure: a Canadian preview comes back as the federal GST alone (rate: 5, rateName: "GST"), and the provincial PST or QST joins it only at checkout once the buyer gives a shipping address, where the checkout's own taxBreakdown lists both. Render note beside the number or the jump at checkout looks like a bug.

Checkout response

{
  "id": "chk_abc123",
  "status": "DRAFT",
  "email": null,
  "customerId": null,
  "deliveryType": null,
  "shippingAddress": null,
  "billingAddress": null,
  "shippingMethod": null,
  "currency": "ILS",
  "subtotal": "499.80",
  "discountAmount": "0.00",
  "ruleDiscountAmount": "0.00",
  "shippingAmount": "0.00",
  "taxAmount": "0.00",
  "total": "499.80",
  "surchargeAmount": "0.00",
  "appliedSurcharges": [],
  "customFieldValues": null,
  "couponCode": null,
  "notes": null,
  "lineItems": [
    {
      "productId": "prod_abc123",
      "variantId": "var_001",
      "name": "Blue Running Shoes - Size 42 / Blue",
      "sku": "BRS-001-42-BL",
      "quantity": 2,
      "unitPrice": "249.90",
      "totalPrice": "499.80",
      "image": "https://..."
    }
  ],
  "itemCount": 2,
  "availableShippingRates": [],
  "expiresAt": "2026-04-10T12:00:00.000Z",
  "createdAt": "2026-04-09T12:00:00.000Z"
}

Step 4.2: Set customer information

PATCH /checkout/{checkoutId}/customer
{
  "email": "[email protected]",
  "firstName": "John",
  "lastName": "Doe",
  "phone": "0501234567",
  "notes": "Please leave the package at the door"
}

email is required. All others are optional but recommended.

Order notes (include by default): every checkout page should include an optional "Order notes" textarea (max 2000 chars) and send its value as notes here. The note is copied onto the order at completion, shown to the merchant in the dashboard, and included in the confirmation email. Send an empty string to clear a previously-entered note. The current value is echoed back as notes on the checkout response.

If the customer is logged in, pre-fill from their profile (see Task 9, step 9.5).

Checkout input attributes: autocomplete, name, inputmode

Every checkout input needs the right autocomplete token, a name, and (for email and phone) an inputmode. Without them the browser and the phone's keyboard cannot fill the shopper's saved details or show the right keyboard, and checkout on a phone becomes typing a full address by hand. This applies to the customer step above and to the shipping and billing address forms below:

FieldnameautocompleteAlso
Emailemailemailtype="email" and inputmode="email"
First namefirstNamegiven-name
Last namelastNamefamily-name
Phonephoneteltype="tel" and inputmode="tel"
Address line 1line1address-line1see the typeahead exception below
Address line 2line2address-line2
Citycityaddress-level2
State, provinceregionaddress-level1
Postal codepostalCodepostal-codeno numeric inputmode: postal codes can contain letters
Country (a <select> of ISO codes)countrycountry

Two rules go with the table. A typeahead line1 (the optional address autocomplete below) uses autocomplete="off" on that one field, because the browser's saved-address dropdown would open on top of yours; every other field keeps its real token. And when the page holds both a shipping and a billing address, prefix the tokens with shipping or billing (for example shipping postal-code) so the browser fills each block from the right saved address.

Step 4.3: Choose the right checkout flow

Decision tree for which steps to show next:

Is storeConfig.hasShipping true?
├── YES → Show delivery type selector (shipping / pickup)
│   ├── User chose "shipping" → Step 4.5 (address) → Step 4.6 (shipping method)
│   └── User chose "pickup" → Step 4.7 (pickup location)
└── NO → Skip steps 4.4–4.7 entirely. Go to Step 4.8 (order summary).

Step 4.4: Set delivery type

PATCH /checkout/{checkoutId}/delivery-type
{
  "deliveryType": "shipping"
}

Values: "shipping" or "pickup".

SDK equivalent:

const checkout = await client.setDeliveryType(checkoutId, 'pickup');
// signature: setDeliveryType(checkoutId, deliveryType: 'shipping' | 'pickup')

Step 4.5: Set shipping address (only if deliveryType = "shipping")

PATCH /checkout/{checkoutId}/shipping-address
{
  "email": "[email protected]",
  "firstName": "John",
  "lastName": "Doe",
  "line1": "Rothschild 123",
  "line2": "Apt 4B",
  "city": "Tel Aviv",
  "region": "Tel Aviv",
  "postalCode": "6100000",
  "country": "IL",
  "phone": "0501234567",
  "notes": "Please leave the package at the door",
  "placeId": "ChIJVXealLU_xkcRja_At0z9AGY",
  "placeSessionToken": "9f1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
}

Required fields: email, firstName, lastName, line1, city, postalCode, country

Give each input its autocomplete token, name and (for email and phone) inputmode from Checkout input attributes, so a phone can fill the whole block from the shopper's saved address.

Send placeId whenever the address came from the autocomplete below. It is the placeId of the suggestion the shopper picked, and the server re-resolves it to that address's exact coordinates. Stores that draw their delivery areas on a map ("polygon" zones) match those coordinates against the drawn shapes; without a placeId the server has to geocode the typed address text instead, which is materially less precise: a same-named street in a neighbouring city can outrank the right one, so the shopper is quoted another area's rate or told there is no delivery at all. placeSessionToken is the same token used for the autocomplete calls and is optional (the resolved place is cached server-side for 24h by placeId).

Clear placeId if the shopper edits any address field after picking a suggestion. The coordinates belong to the suggestion, not to what is in the inputs now, so a stale placeId would match zones against the address they originally picked. Dropping it falls back to geocoding the edited text, which is the right behaviour once the two disagree.

There is deliberately no lat/lng field: zone matching decides which shipping rate is offered and charged, so coordinates are never accepted from the client. The server resolves them from placeId itself.

This endpoint rejects any unknown property outright with 400 "property lat should not exist", which blocks checkout entirely rather than degrading. Since getAddressDetails() resolves an address that does carry lat, lng and formattedAddress, the SDK strips those three from setShippingAddress() / setBillingAddress() bodies for you (SDK ≥ 1.53.0), so spreading a resolved address is safe there. Calling the REST endpoint directly? Omit them yourself, along with anything else not in the payload above.

notes is the optional order-level note from the "Order notes" textarea every checkout page should include by default (max 2000 chars). It can be sent here or in step 4.2. Either way it lands on the order, visible to the merchant and included in the confirmation email.

After this call: check the response for availableShippingRates. If not empty → show shipping method selection (step 4.6).

To know which countries are available for shipping:

GET /shipping/destinations

Returns an object, not an array:

{
  "worldwide": false,
  "countries": [
    { "code": "IL", "name": "Israel" },
    { "code": "US", "name": "United States" }
  ],
  "regions": {
    "US": [
      { "code": "CA", "name": "California" },
      { "code": "NY", "name": "New York" }
    ]
  }
}

countries lists every country an active shipping zone names, sorted by code; regions holds a per-country list only for countries whose zones restrict by region, so treat a missing key as "any region". worldwide: true means at least one zone ships everywhere (or is a drawn-shape zone whose coverage cannot be listed), so render a full country picker in that case. The route exists on the sales-channel API only: a storeId storefront has no destinations read (404) and an admin key gets 403 STORE_SCOPE_REQUIRED, so build the country list from your own shipping settings in those modes.

Optional: address autocomplete on the line1 field

Instead of a free-text line1 input, you can turn it into a typeahead that suggests real addresses and flags whether each one falls inside the store's configured shipping zones (see getAddressSuggestions() / getAddressDetails() in the SDK reference). This mirrors the "Delivery address" pattern used by food-delivery checkouts: suggestions as the shopper types, then a soft "outside our regular delivery zones, we'll confirm by phone" banner if they pick (or type) an address outside coverage. It's a soft signal, never a hard block, so still let the shopper continue.

Suggestions are intentionally NOT restricted by the store's shipping countries. An out-of-country suggestion the shopper just won't pick is a far smaller problem than silently hiding a genuinely valid address because of an incomplete/wrong restriction. The zone check after selection is the real gate; narrowing the dropdown itself isn't worth that risk.

They ARE restricted to deliverable address types: street addresses, routes, buildings and sub-premises. Businesses, stations, hospitals and other establishments never appear, because a courier cannot deliver to one and the merchant needs a street and a number on the order. A shopper who types only a landmark name gets no suggestions and has to type the street.

inZone is evaluated against the same region a checkout would resolve, so it no longer disagrees with the rates you get later: the zone's currency-region restriction is judged by the destination country first, then the regionId you pass here, then the store's default region. Passing regionId is still worthwhile when the shopper has already chosen a region, because it makes the answer exact rather than default-based.

const sessionToken = crypto.randomUUID(); // generate once when the field is focused

// Debounce this call (~300ms) as the shopper types — don't fire on every keystroke
const suggestions = await client.getAddressSuggestions('Rothschild 1', sessionToken);

// On selecting a suggestion — pass the SAME sessionToken to end the session
const picked = suggestions[0];
const { address, inZone } = await client.getAddressDetails(picked.placeId, sessionToken);
if (!inZone) {
  // Show a non-blocking warning; still let the shopper continue to step 4.6
}

// `address.country` (like `address.region`) can be an EMPTY STRING. Google
// omits the country outright for places whose sovereignty it declines to
// attribute — that includes ordinary residential addresses, so this is a
// normal response, not an error. Leave your country field for the shopper to
// confirm instead of submitting a blank one; the rest of the address is valid.
if (!address.country) {
  // prompt for country; line1/city/postalCode from the resolution are still good
}

// Keep the placeId and hand it to setShippingAddress — that, not the
// coordinates, is what lets the server match map-drawn ("polygon") zones
// against exact coordinates instead of re-geocoding the typed text. Skipping it
// is the single most common cause of "the store says it doesn't deliver here,
// but it does".
//
// The spread is safe through the SDK: `address` carries lat/lng/formattedAddress
// and the SDK drops those three before sending. Over raw HTTP the same body is
// a 400 — build it field by field there.
await client.setShippingAddress(checkoutId, {
  ...address,
  email,
  firstName,
  lastName,
  placeId: picked.placeId,
  placeSessionToken: sessionToken,
});

Validate region before auto-filling it. address.region is this platform's own region code for the resolved place (the same code the destinations.regions lists use), already reconciled server-side from whatever text Google returned, and an empty string when nothing could be matched. An empty value must leave the field for the shopper to pick, so before assigning it into the region <select>, check it against destinations.regions[address.country] (matching by code, the same list that dropdown renders):

const validRegions = destinations.regions[address.country] ?? [];
const region = validRegions.some((r) => r.code === address.region) ? address.region : '';

If it's not a recognized code, leave the field for the shopper to pick manually rather than assigning a value the dropdown won't recognize.

Step 4.6: Select shipping method (only if availableShippingRates not empty)

PATCH /checkout/{checkoutId}/shipping-method
{
  "shippingRateId": "rate_abc"
}

The availableShippingRates array:

[
  {
    "id": "rate_abc",
    "name": "Standard Shipping",
    "price": "29.90",
    "estimatedDays": 5,
    "source": "manual"
  },
  {
    "id": "carrier:shp_1a2b:rate_9x8y",
    "name": "USPS PriorityMailInternational",
    "price": "68.14",
    "estimatedDays": 3,
    "source": "carrier",
    "carrier": "USPS",
    "speedTier": "fastest"
  }
]

Display as radio buttons, cheapest first (the order they arrive in). Pre-select the first option. After selection, the checkout total updates automatically in the response.

Do not render name for live carrier rates. It carries the carrier's own service identifier, such as USPS PriorityMailInternational or USAExportPBA USAExportStandard, which means nothing to a shopper. They are choosing between how fast and how much, so show speedTier in your own words and estimatedDays.

speedTier has exactly three values: 'cheapest', 'balanced', 'fastest'. It is optional: manual zone rates carry no speedTier at all, so write the label lookup to fall through rather than indexing blind.

const LABELS = {
  cheapest: 'Standard delivery',
  balanced: 'Express delivery',
  fastest: 'Priority delivery',
};

// Manual zone rates carry no speedTier — the merchant named those
// deliberately, so show their name exactly as written.
const label = rate.speedTier ? LABELS[rate.speedTier] : rate.name;

Carrier rates are already narrowed for you: the cheapest, the fastest, and one genuinely in between when it earns its place. You will not receive seven near-identical services to filter yourself. The tiers are derived from each quote rather than mapped from service names, so a carrier you have never heard of tiers correctly with no lookup table.

Step 4.6b: Choose a carrier pickup point (only if the selected rate has requiresPickupPoint: true)

A merchant can offer a rate that delivers to a carrier pickup point: a partner shop or a locker where the shopper collects the parcel. It is not the store's own pickup (Step 4.7). It arrives in availableShippingRates as an ordinary rate with one extra field:

{
  "id": "rate_point",
  "name": "Pickup point",
  "price": "20.00",
  "estimatedDays": 4,
  "source": "manual",
  "requiresPickupPoint": true
}

You only receive that rate if you declared you can show the choice. Send X-Brainerce-Features: pickup-points on your requests (the SDK does it with new BrainerceClient({ features: ['pickup-points'] })). A storefront that does not declare it is never offered the rate, because a shopper who chose it could not pay. Declare it only once the screen below exists.

After the shopper selects a rate with requiresPickupPoint:

GET /checkout/{checkoutId}/pickup-points
PUT /checkout/{checkoutId}/pickup-point        { "id": "510" }
const points = await client.getPickupPoints(checkoutId); // nearest first
const checkout = await client.selectPickupPoint(checkoutId, points[0]);
checkout.pickupPoint; // { id, name, kind, address, latitude, longitude, hours, note }

Each point:

FieldMeaning
idOpaque. Send it back to choose the point.
name, addressShow as written.
kindstore, locker or other. Use it for an icon, nothing else.
hours, noteThe carrier's own words, when it gives any. Display only: never parse hours. Often absent.
latitude, longitudePut it on a map. Absent when the carrier gives no position.
distanceKmStraight-line distance from the shopper, when the search had a position.

The list is searched around the shipping address. To search somewhere else (a map the shopper moved, another city) add ?latitude=&longitude= (both), ?city=, ?postalCode= or ?limit= (1 to 50).

Rules that are easy to get wrong:

  • An empty list and an error are different. [] means no point is near. HTTP 502 means the carrier could not be reached: offer a retry, never "no pickup point near you".
  • The server stores what the carrier lists for that id, not what you send. Anything you send besides id only says where the point was found. An id the carrier no longer lists answers 404: refresh the list.
  • Changing the shipping address or the shipping method clears the point. Choose again. Choosing the same pickup rate again keeps it.
  • You cannot pay or complete without one. createPaymentIntent and completeCheckout answer 400 PICKUP_POINT_REQUIRED while a pickup point rate is selected and no point is chosen. Disable the pay button until checkout.pickupPoint is set.
  • On the order the point is order.shippingPickupPoint. The parcel goes there; shippingAddress is still the shopper, and the carrier contacts them when it arrives.

Step 4.7: Select pickup location (only if deliveryType = "pickup")

Click and collect. Fetch the store's pickup points, let the shopper choose one, and send it back with their contact details. The pickup route is the pickup-flow equivalent of shipping-address plus shipping-method together: it records the contact details and the chosen point in one call, so after it succeeds you go straight to payment. You do not need to call the shipping-address or shipping-method routes on a pickup checkout.

GET /pickup-locations

Returns available locations. An empty array means the merchant has configured none, so do not render a pickup option at all. Then:

PATCH /checkout/{checkoutId}/pickup-location
{
  "pickupRateId": "pickup_abc",
  "email": "[email protected]",
  "firstName": "John",
  "lastName": "Doe",
  "phone": "0501234567"
}

SDK equivalents:

const locations = await client.getPickupLocations(); // Promise<PickupLocation[]>
if (locations.length === 0) {
  // No pickup points configured. Hide the click-and-collect option entirely.
}

// `pickupRateId` is the location's `id` from the array above.
const checkout = await client.selectPickupLocation(checkoutId, {
  pickupRateId: locations[0].id,
  email: '[email protected]',
  firstName: 'John',
  lastName: 'Doe',
  phone: '0501234567',
});
// Now proceed to payment (Task 5). No shipping method needed.

Each PickupLocation carries id, name, rateName and an address, so you can render a real address per point rather than a bare list of names.

Step 4.7b: Checkout custom fields (only if the merchant defined any)

Merchants can define extra checkout inputs in the dashboard under Checkouts → Custom Fields: a gift message, a company tax ID, a floor number, a delivery date. Each one can carry a surcharge, and each can be limited to certain products or to shipping-only orders, so the applicable set depends on the checkout you are looking at. Fetch it per checkout rather than caching it per store.

features.hasCheckoutCustomFields in the capabilities payload tells you whether the store has any at all; the checkout route tells you which ones apply right now.

GET   /checkout/{checkoutId}/custom-fields
PATCH /checkout/{checkoutId}/custom-fields
// 1. Which fields apply to THIS checkout right now?
const fields = await client.getCheckoutCustomFields(checkoutId);
// Promise<CheckoutCustomFieldDefinition[]>

// 2. Render one input per definition, then send the values back.
//    The map is REPLACED wholesale, not merged, so send every field the
//    shopper filled in, not just the one that changed.
const checkout = await client.setCheckoutCustomFields(checkoutId, {
  gift_wrapping: 'premium', // SELECT
  floor_number: 5, // NUMBER
  installation: true, // BOOLEAN
});
// checkout.surchargeAmount and checkout.total are recalculated by this call

Required fields are enforced here and nowhere else. The check runs inside the PATCH. A storefront that never calls it completes the order with no custom-field values at all, required ones included, and nothing complains. If you build your own checkout UI, calling this is on you.

Storefront surface only. These two routes exist on /vc/{salesChannelId}/… and /stores/{storeId}/…. There is no equivalent on the API-key /v1 surface, and the SDK throws in admin mode rather than 404ing silently.

definition.type is one of eight values. Render a control per type and treat an unknown value as plain text:

typeControlValue you send back
TEXT<input type="text">string, respects minLength / maxLength
TEXTAREA<textarea>string, respects minLength / maxLength
NUMBER<input type="number">number, respects minValue / maxValue
BOOLEANcheckbox or switchboolean
SELECT<select> built from definition.optionsthe option's value string
DATEdate pickerYYYY-MM-DD string
DATETIMEdate and time pickerISO timestamp string
IMAGEfile uploadthe uploaded file URL, validated server-side to be a store-hosted upload

GALLERY and MULTI_SELECT are not available for checkout fields. Those are product metafield types only, so do not build branches for them here.

The definition you get back (one object per applicable field, in ascending position):

FieldMeaning
id, storeIdThe definition's ids.
keyThe key to send the answer under, inside fields. Unique per store, 1 to 64 characters.
name, descriptionLabel and helper text (up to 100 and 500 characters). description can hold line breaks: render them.
typeOne of the eight values above.
requiredtrue when the shopper must answer. Not isRequired.
isActiveAlways true here, only active fields are listed.
visibility{ show: 'always' | 'shipping' | 'pickup' | 'products', productIds?, categoryIds? }. The route has already filtered to the fields that apply to this checkout, so you do not evaluate it.
pricingThe surcharge rule, below. You never compute it.
optionsSELECT only: { value, label }[].
minLength, maxLengthTEXT and TEXTAREA: number or null.
minValue, maxValueNUMBER: a decimal string such as "10", or null. parseFloat() before comparing.
dateAvailabilityDATE and DATETIME constraints, or null.
translationsBy locale code, or null.
createdAt, updatedAtISO 8601 timestamps.

Surcharges. pricing is one of { type: 'none' }, { type: 'fixed', amount } (added whenever the field is answered), { type: 'boolean_checked', amount } (added when the answer is true), { type: 'per_option', options: [{ value, label, price }] } (the price of the chosen option) or { type: 'conditional', condition: { operator, threshold, amount } } (a NUMBER answer compared with the threshold, operator is gt, gte, lt, lte or eq). Amounts are in the store base currency and are never negative. A field left empty adds nothing. The response of PATCH is the checkout with surchargeAmount and total already recalculated.

What the PATCH checks. The body is { "fields": { "<key>": <value> } } and it replaces the stored answers. Every applicable field that is required must be present and not null or "": a missing one answers 400 Required checkout field "<name>" (<key>) is missing. false and 0 count as answers, so a required BOOLEAN cannot force the shopper to tick the box (an unticked false passes); do not use one for terms consent. A value outside the field's rules answers 400 Invalid value for checkout field "<name>" (<key>): <reason>: too short or too long, a number outside minValue and maxValue, a SELECT value that is not one of the options, a date the dateAvailability rules refuse, an IMAGE URL that is not a file uploaded through the store. A key that is not an applicable field is ignored. BOOLEAN also accepts "true", "false", 1, 0; NUMBER accepts a numeric string. Every answer is stored in canonical form (a DATETIME without an offset is read in the store's time zone). A locked checkout (payment in progress, code: CHECKOUT_LOCKED), an expired or a completed one answers 400.

DATE and DATETIME definitions can carry dateAvailability constraints (blocked weekdays, blocked dates, business hours, slot length). Evaluate those against the store's timezone from getStoreInfo().timezone, never the shopper's browser timezone, using the SDK's computeAvailableSlots() helper.

Step 4.8: Set billing address (optional)

Only if billing is different from shipping:

PATCH /checkout/{checkoutId}/billing-address
{
  "sameAsShipping": false,
  "firstName": "Jane",
  "lastName": "Doe",
  "line1": "Herzl 45",
  "city": "Haifa",
  "postalCode": "3100000",
  "country": "IL"
}

If same as shipping: skip this step, or send { "sameAsShipping": true }.

Step 4.9: Order summary

Before proceeding to payment, display a final summary from the latest checkout response:

FieldDisplay
lineItemsProduct names, quantities, unit prices
subtotalSubtotal
discountAmountCoupon discount (show only if > 0)
ruleDiscountAmountAutomatic discount (show only if > 0)
shippingAmountShipping cost (show only if > 0)
taxAmountTax (show only if > 0). 0 in VAT-inclusive stores, so read taxBreakdown.totalTax and label "Tax (incl.)"
taxBreakdown{ subtotal, shippingNet, totalTax, total, pricesIncludeTax, taxCoverage?, breakdown[] }. Loop breakdown[], never read breakdown[0] — see below
surchargeAmountSurcharges (show only if > 0)
totalWhat the order is worth, and the amount tax was calculated on
tendersGift cards applied to this checkout, [{ tenderId, amountApplied }]. One line below the total, each
providerAmountDueWhat the customer's card is actually charged: total minus every gift card. Equals total when there are none

Always use the total field from the response. Never calculate it yourself.

taxBreakdown.subtotal is not the subtotal row

The subtotal field at the top of the table is goods-only and is the one to put on the summary. The subtotal inside taxBreakdown is a different number: it is the net of every taxed line and it includes the shipping net, so a summary built from it prints subtotal + shipping + tax and counts shipping twice. One checkout rendered 54.06 + 9.99 + 7.93 = 71.98 beside the 61.99 it was charging.

If you do need a goods-only row out of the tax object, use taxBreakdown.subtotal - taxBreakdown.shippingNet. Subtract shippingNet, not the gross shippingAmount: the two are equal only while shipping is untaxed, so that shortcut is right until a store taxes delivery and then silently understates the subtotal.

A gift card is a means of payment, not a discount

If the shopper applied a gift card (Optional Integration, Task 19), total does not go down and discountAmount does not go up. Tax is still calculated on the full order value. What drops is providerAmountDue, the amount the payment provider will be charged.

So the summary gains two lines under the total, and loses nothing:

Subtotal      ₪160.00
Discount      −₪0.00
Shipping      ₪20.00
Tax           ₪25.00
Total         ₪205.00      ← unchanged by the card
Gift card     −₪54.50      ← one line per entry in checkout.tenders
Amount due    ₪150.50      ← checkout.providerAmountDue

⛔ Never add a gift card into the discount block, and never subtract it from total. Stored value shown as a price reduction tells the shopper — and their receipt — that the order was taxed on a smaller amount than it actually was.

Render the cards from checkout.tenders on every load, not from the response you kept when the shopper applied one: the hold lives on the server, so a storefront reading its own state shows an empty summary after a page refresh while the card is still applied. tenders is absent or empty when no card is applied, and providerAmountDue then equals total, so the two extra lines simply do not render on an ordinary order.

More than one tax on the same order

taxBreakdown.breakdown[] holds one row per tax rate, and in several countries that is routinely more than one row. A Quebec order carries GST 5% and QST 9.975%; a British Columbia one carries GST 5% and PST 7%. Both are charged on the same pre-tax amount, never on each other, and a GST/QST-registered merchant is legally required to show them separately.

So render every row rather than the first one:

{checkout.taxBreakdown?.breakdown.map((tax) => (
  <div key={tax.name}>
    <span>{tax.name}</span>
    <span>{formatMoney(tax.amount, checkout.currency)}</span>
  </div>
))}

breakdown can also be empty or missing on an order frozen before the field existed, so keep a fallback to the single taxAmount line. Do not sum the rows to get the total either — use taxBreakdown.totalTax, which is the figure the order was actually charged.

Provinces on HST (Ontario, New Brunswick, Newfoundland, Nova Scotia, Prince Edward Island) carry a single combined row, because HST already contains the federal part. Nothing in the response tells you which case you are in; loop the array and it works for both.

Discounts reduce the taxable amount

Tax is calculated after discounts. Coupons, loyalty redemptions, bundle offers and automatic discount rules (including fixed-amount ones) all lower the amount tax is charged on. A gift card is the exception, because it is a payment, not a discount: see above. Shipping is taxed with the Standard rates unless the shipping rate's tax status is NONE.

Zero tax, and why: taxCoverage

There is no global fallback rate. Every tax rate names a country, and a buyer whose country has no rate pays zero tax, silently. taxBreakdown.taxCoverage tells you which case you are in:

ValueMeaning
'full'Every non-exempt line matched a rate
'partial'Some non-exempt lines matched no rate and were taxed 0
'no_rate'No line matched a rate (usually: no rate for the buyer's country)
'disabled'The store has tax switched off

It is a diagnostic for the merchant (a dashboard, a log line, an alert), not copy for the buyer: do not print "tax not configured" on a storefront. It is optional and absent on orders placed before it shipped, so treat undefined as "unknown", never as 'full'. It never changes the totals.

Rate matching, for reference: the most specific rate wins (an exact postal code beats a region, a region beats the country), the lower priority breaks a tie between rates scoped the same way, and stackable rates are summed. Postage is taxed by every rate in that stack, except a rate with appliesToShipping: false, which is left off postage (the items still pay it) without moving postage onto a less specific rate. region matches by code or name (Quebec, qc and CA-QC all mean QC). Postal codes match exactly after ignoring case, spaces and hyphens; there are no ranges, prefixes or wildcards. Whether prices already include tax is region.taxInclusive ?? store.pricesIncludeTax for the checkout's region (a region left at null follows the store), and taxBreakdown.pricesIncludeTax reports the value used. The per-rate isInclusive flag is not read by the calculation.

Rates also have an optional date window. A rate is charged only while it is isActive and effectiveFrom <= now < effectiveTo (ISO 8601, the end instant itself is not charged, an empty bound is open). A rate with a future effectiveFrom is scheduled and an expired one is ignored by checkout, the estimate and calculateTax; a rate with no dates behaves as it always did. To change a rate from a date, never delete the old one: first updateTaxRate(oldId, { effectiveTo: T }), then createTaxRate({ ..., effectiveFrom: T }). Two rates on the same country, region, postal code and tax class may coexist only while their windows do not overlap, so creating the successor first is a 409.


Task 5: Payment

This is the most critical task. With the SDK the whole payment step is one call, client.startPayment(checkoutId, options), and it is the same call for every provider the store can install. Your code never branches on a provider name or a render type.

Payment flow overview

Step 5.1: Get available payment providers (express buttons, test-mode banner)
Step 5.2: Create payment intent (startPayment does this for you)
Step 5.3: client.startPayment(): create the payment and show it, any provider
Step 5.4: Return pages: /payment-complete and /order-confirmation
Step 5.5: Complete checkout (nothing owed, or the REST sandbox path)

GET /payment/config (client.getPaymentConfig()) is a deprecated single-provider shortcut that returns { provider, publicKey, stripeAccountId?, supportedMethods, testMode } for the one settling provider (sales-channel mode only, needs checkout:write or orders:write, 404 when no provider is configured). Do not build on it: use getPaymentProviders() (Step 5.1) and startPayment().

Using the REST API without the SDK (Method B)? Steps 5.2 to 5.5 also document the endpoints startPayment calls, and Without the SDK shows how to render each mode yourself.

Step 5.1: Get payment providers

GET /payment/providers

Response is an envelope, not a bare array:

{
  "hasPayments": true,
  "providers": [
    {
      "id": "inst_stripe123",
      "name": "Stripe Payments",
      "provider": "stripe",
      "publicKey": "pk_live_...",
      "supportedMethods": ["card"],
      "testMode": false,
      "methodType": "CREDIT_CARD",
      "presentation": "card_form",
      "isAdditive": false,
      "isDefault": true,
      "supportsSavedCards": true,
      "clientSdk": { "renderType": "redirect", "displayModes": ["redirect"] }
    },
    {
      "id": "inst_paypal456",
      "name": "PayPal",
      "provider": "paypal",
      "publicKey": "...",
      "supportedMethods": ["paypal", "card"],
      "testMode": false,
      "methodType": "WALLET",
      "presentation": "express_button",
      "isAdditive": true,
      "isDefault": false,
      "supportsSavedCards": true,
      "clientSdk": { "renderType": "redirect", "displayModes": ["redirect"] }
    }
  ],
  "defaultProvider": { "id": "inst_stripe123", "...": "same object as in providers" }
}

Read providers and defaultProvider off the envelope; SDK getPaymentProviders() returns the same object.

The list is not filtered by currency. It takes no checkout, so every enabled provider is listed whatever currency the checkout is priced in, and a provider that cannot take that currency is refused only when you create the payment intent, with presentment_currency_unsupported (see the error table below). PayPal, for example, takes 24 currencies (AUD, BRL, CAD, CNY, CZK, DKK, EUR, GBP, HKD, HUF, ILS, JPY, MXN, MYR, NZD, NOK, PHP, PLN, SGD, SEK, CHF, THB, TWD, USD): a checkout pinned to any other currency (INR, KRW, AED) is refused for PayPal before any charge. Handle that 400 by letting the buyer pay with another provider or continue in the store's base currency.

Fields on each provider. id (the installation id: pass it as providerId), provider (the provider's slug, a free string: never branch on it), name, publicKey (empty string when the provider has none), stripeAccountId (only on a provider that has a connected account), supportedMethods, testMode, isDefault, methodType, presentation, isAdditive, supportsSavedCards and clientSdk (absent when the provider declares none).

supportedMethods says which payment methods the provider's own payment page or widget can offer. It is a fixed declaration per provider, not a check of the merchant's account.

ValueMeaning
cardCards. Every provider lists it.
bitThe Bit wallet (Israel).
apple_payApple Pay.
google_payGoogle Pay.
paypalPayPal wallet payments.
sandboxThe platform's test mode. Only ever the synthetic sandbox provider.
  • Read it as "may be offered", never as "will be shown". The method also has to be enabled for the merchant's own terminal or account at the provider, and the platform has no call that reports which ones are. The list is the same for every store that installed the provider, and a store's settings do not change it (a provider with per-method switches in the dashboard keeps its list as it is).
  • A provider whose page offers wallets can still list only card. The list follows what the platform recognises when a payment comes back, so absence means "the platform does not name this method", not "the page cannot show it".
  • Do not draw a wallet button yourself. Wallets (Apple Pay, Google Pay, Bit) are shown by the provider's page, widget or card form, which startPayment renders. The only buttons you draw are for an additive provider (isAdditive: true, presentation: 'express_button'), one per entry, and you never branch on a value in this list.
  • Apple Pay in an in-page widget needs domain verification. A provider that renders its Apple Pay button in a widget on your page (clientSdk.renderType: 'sdk-widget') shows it only on a domain that Apple has verified for that provider. The verification file is served by your storefront at /.well-known/apple-developer-merchantid-domain-association. A storefront from npx create-brainerce-store already serves it from a route handler. A storefront built another way has to serve the same file at that path, as text/plain; copy the route handler from the starter template (src/app/.well-known/apple-developer-merchantid-domain-association/route.ts). Without it the button does not appear.

Primary vs. additive (Shopify-parity). methodType splits providers into a single primary card processor (CREDIT_CARD, the defaultProvider, which settles the order) and additive methods (isAdditive: true, e.g. PayPal as a WALLET). Render additive methods as accelerated-checkout express buttons above the card form. They sit alongside the primary, never replace it. When the buyer taps one, call client.startPayment(checkoutId, { providerId: provider.id, ... }) (REST: POST /payment/intent with that provider's id). (A wallet-only store is the exception: with no card processor, its wallet becomes the defaultProvider and stands alone.)

hasPayments is always true and providers is never empty, so neither one tells you the merchant has not set up payments: a store with no active payment app (never installed, uninstalled, or suspended) gets a synthetic sandbox provider back (id: "sandbox", clientSdk.renderType: "sandbox"), and creating an intent against it is refused unless sandbox payments are switched on for the channel. The real signal is POST /payment/intent (Step 5.2) failing with HTTP 503 and code: "no_active_payment_provider": with the SDK that arrives as onError with code: 'unavailable' (the original BrainerceError is on error.cause). Catch it, show "Payment is not configured" and block checkout there. Do not branch on an empty providers list, because that branch never runs.

clientSdk on each provider says how it renders, and startPayment reads it for you. clientSdk.renderType is the provider's default mode, and clientSdk.displayModes lists every mode the provider can serve. Examples:

ProviderrenderTypeWhat the shopper sees
StriperedirectStripe's hosted Checkout page; it returns to your success URL with session_id. There is no card form on your page.
PayPalredirectPayPal's approve page; it returns with token and PayerID. There are no PayPal JS buttons.
SolaiframeA Brainerce-hosted card form (/api/payment/embed/..., inline).
Other providersany mode listed heresdk-widget (the provider's JavaScript window), iframe (a Brainerce-hosted card form or the provider's page) or redirect (the provider's page). The mode comes from the API: read renderType and displayModes, never a provider name.
sandboxsandboxNothing: the platform's test mode. Place a test order.

A provider that lists both redirect and iframe in displayModes serves its page as a redirect by default, or inside an iframe when you pass preferredRenderType: 'iframe'.

'embedded-fields' is a reserved value: no provider produces it today. displayModes is absent on an older manifest; treat that as [renderType].

Step 5.2: Create payment intent

With the SDK, startPayment (Step 5.3) creates the intent for you, with the right return URLs for the mode the provider will use. Read this step for the rules that still apply to you and for the REST shape.

First, check whether anything is owed. When gift cards cover the whole order (checkout.providerAmountDue === "0.00") there is no charge to make: skip this step and Step 5.3 entirely (do not call startPayment) and go straight to Step 5.5, completeCheckout. The platform allows completion without a captured payment in exactly this case, because the amount owed is derived server-side from the holds it placed itself, and the order it creates is a real paid order, not a test one. Then do Task 6 normally: the cart clear is still owed on this path, and completeCheckout returns the orderId directly, so there is nothing to poll for.

POST /payment/intent
{
  "checkoutId": "chk_abc123"
}

providerId (optional). Route the charge to a specific installed provider by passing the id from a getPaymentProviders() entry. Send it when the buyer taps an additive express button (e.g. PayPal as a WALLET); omit it to settle through the store's primary card processor (defaultProvider). The platform scopes this to the store, so only that store's own installed providers are selectable. startPayment takes the same providerId option.

{
  "checkoutId": "chk_abc123",
  "providerId": "inst_paypal_xyz"
}

preferredRenderType (optional). How you would like the provider's payment surface presented: 'redirect' or 'iframe'. The platform honours it when the provider lists that mode in its clientSdk.displayModes (Step 5.1), and otherwise returns the provider's default. It is never an error to ask, so ask optimistically. Omit it to take the provider's default, which is what every storefront did before this option existed. No dashboard setting picks this: the storefront knows whether it has a frame to render into, the merchant does not. startPayment takes the same option.

{
  "checkoutId": "chk_abc123",
  "preferredRenderType": "iframe"
}

Without the SDK, predict the mode before you create the intent, because your successUrl depends on it. (startPayment does this for you.) An iframe intent returns the shopper inside the frame, so its successUrl must be a same-origin page that posts brainerce:payment-complete to the parent window; a redirect intent returns them straight to your confirmation page. The SDK's resolveRenderType(provider.clientSdk, preferred) applies exactly the rule the platform does (preference if declared in displayModes, else the provider default), so the two never disagree:

import { resolveRenderType } from 'brainerce';

const { defaultProvider } = await client.getPaymentProviders();
const expected = resolveRenderType(defaultProvider?.clientSdk, 'iframe');
const successUrl =
  expected === 'iframe'
    ? `${origin}/payment-complete?checkout_id=${checkoutId}`
    : `${origin}/order-confirmation?checkout_id=${checkoutId}`;
const intent = await client.createPaymentIntent(checkoutId, {
  preferredRenderType: 'iframe',
  successUrl,
});

expectedTotal (recommended). The checkout.total the buyer is looking at, as a number (Number(checkout.total), store base currency, major units). Tax can change while the buyer is on the payment step (a rate starts, ends or is edited; a discount or the shipping price moves), so the platform re-verifies tax against the current cart, address, shipping, discount and tax rates right before it creates the payment. If the total is not exactly expectedTotal (to the currency's smallest unit) it answers HTTP 409 CHECKOUT_TOTAL_CHANGED and creates and charges nothing:

{
  "statusCode": 409,
  "code": "CHECKOUT_TOTAL_CHANGED",
  "message": "The order total changed since it was last shown ...",
  "previousTotal": 117,
  "newTotal": 112,
  "previousTax": 17,
  "newTax": 12,
  "currency": "ILS"
}

Show the buyer newTotal and ask them to confirm. When they accept, call again with expectedTotal set to newTotal; that charges exactly that amount. Never retry with the new total without showing it. With the SDK, isCheckoutTotalChangedError(err) narrows the error and err.details is this body. With a gift card applied, send the order total (checkout.total), not providerAmountDue. Every figure is in the store base currency: send checkout.total, never a converted presentment total (a storefront that shows converted prices has to convert newTotal itself). A payment page that is already open for this checkout keeps the total it was created with, and expectedTotal is checked against that total. Omit expectedTotal and the call behaves as before, except that the freshly verified total is charged (never a stale one) without asking the buyer to confirm a change. startPayment takes the same option and reports the 409 as onError with error.code === 'total_changed'.

Collect the state or province (US, Canada, Australia). When the store charges tax by state or province in the buyer's country and the shipping address has no region, the call answers HTTP 400 TAX_ADDRESS_REGION_REQUIRED and creates and charges nothing: charging it would collect only the country-wide part of the tax. The body is flat: { "statusCode": 400, "code": "TAX_ADDRESS_REGION_REQUIRED", "message": "...", "country": "CA" }. Ask the buyer for the state or province, update the shipping address with region (the ISO code such as QC or the name), and call again. With the SDK, isTaxAddressRegionRequiredError(err) narrows it and startPayment reports it as onError code region_required. A country where the store has only country-wide rates, an address that already has a region, a cart with no address at all and a store with tax off are never blocked. So always collect region for US, CA and AU in the shipping form.

A country the store refuses (HTTP 400 TAX_NO_RATE_FOR_DESTINATION). A merchant can choose not to take orders to a country that has no tax rate. Then createPaymentIntent answers HTTP 400 TAX_NO_RATE_FOR_DESTINATION before anything is charged, with the flat body { "statusCode": 400, "code": "TAX_NO_RATE_FOR_DESTINATION", "message": "...", "country": "DE" } (completeCheckout answers it too for an order that needs no payment, such as one a gift card covers in full). Retrying the same address cannot work: show the message and let the buyer pick another delivery address. It is off by default, and never raised for a country the merchant marked "not collected", a store with tax off, or a checkout with no address.

Which address is taxed. The shipping address when the checkout has one. A digital-only checkout asks for none, so it is taxed at the billing address instead (set it with setBillingAddress; the tax and total update the moment it is saved, and the same region rule above applies to it). A checkout with neither address is not taxed, so collect a billing address on a digital-only checkout.

Render by the clientSdk.renderType that comes BACK, never by what you asked for. The response's renderModeResolution says what happened: 'preferred' (you asked and the provider supports it), 'fallback' (you asked for a mode the provider does not declare and got its default), 'provider-default' (you did not ask, or the intent is a sandbox one). Any value other than 'redirect' or 'iframe' in preferredRenderType is rejected with 400; 'sdk-widget', 'embedded-fields' and 'sandbox' are integration mechanisms, not presentation preferences.

Response (Stripe, a redirect provider):

{
  "id": "cs_live_abc123",
  "clientSecret": "https://checkout.stripe.com/c/pay/cs_live_abc123",
  "amount": "499.80",
  "currency": "ILS",
  "status": "pending",
  "provider": "stripe",
  "metadata": {},
  "clientSdk": { "renderType": "redirect" },
  "renderModeResolution": "provider-default"
}

amount already has gift cards netted off. The server subtracts every live gift-card hold before it quotes the provider, so charge exactly what the intent says and never subtract amountApplied yourself: doing it twice undercharges the order. It also means gift cards must be applied and removed before this call (before startPayment): once the checkout is PAYMENT_PENDING, both fail with CHECKOUT_LOCKED.

This call can fail with 503 and code: "PAYMENTS_PAUSED". The platform pauses a store's payments while its ownership is being transferred, and the pause lasts until the new owner connects a payment provider, so it can run for days. With the SDK it arrives as onError with code: 'unavailable' and the BrainerceError on error.cause. Show the response's message ("Payments are temporarily unavailable for this store") at the payment step, keep the cart and the checkout intact, and do not retry in a loop: nothing on the storefront side lifts it. See Common payment errors.

Step 5.3: Show the payment with startPayment

One call creates the payment and shows it the way that payment needs: a redirect, a provider page in an iframe, a Brainerce-hosted card form, a JavaScript widget, or a test order. It decides from the render type the platform returns, never from a provider name, so a provider the merchant installs next month works without a storefront change.

⛔ Never branch on clientSdk.renderType or on the provider name yourself, never call a provider global (window.<providerGlobal> and the like), and never build the payment iframe, its postMessage listener or the redirect yourself. Storefronts that wrote this logic broke on a live store three different ways in one day.

import { PaymentFlowError, type PaymentSession } from 'brainerce';

// One element that stays mounted for the whole payment step. A 'provider-page'
// is shown in a modal by styling a wrapper AROUND this element, never by
// moving it into a portal or rendering it conditionally.
const container = document.getElementById('payment-container')!;
const wrapper = document.getElementById('payment-wrapper')!;
let session: PaymentSession | null = null;

// Start on the shopper's click ("Continue to payment"), never on page load:
// a redirect provider navigates away as soon as the payment exists, and
// creating it locks the checkout (gift cards can no longer change).
payButton.addEventListener('click', () => {
  session?.destroy();
  session = client.startPayment(checkoutId, {
    container,
    saveCard: saveCardCheckbox.checked, // only when the shopper ticked an unchecked-by-default box
    onStatus: (status) => setBusy(status === 'preparing' || status === 'processing'),
    onPresentation: (presentation) => {
      // 'provider-page' → modal; 'embedded' and 'widget' → inline;
      // 'redirect' → the browser is leaving; 'sandbox' → test-order button.
      wrapper.classList.toggle('modal', presentation === 'provider-page');
      testOrderButton.hidden = presentation !== 'sandbox';
    },
    onSuccess: ({ checkoutId: id }) => {
      // Fires only after the SERVER has the order.
      window.location.href = `/order-confirmation?checkout_id=${encodeURIComponent(id)}`;
    },
    onFailure: (message) => showError(message), // declined or cancelled: "Try again" calls session.reopen()
    onError: handlePaymentError,
  });
});

testOrderButton.addEventListener('click', () => void session?.completeTestOrder());
tryAgainButton.addEventListener('click', () => session?.reopen());
window.addEventListener('pagehide', () => session?.destroy());

function handlePaymentError(error: PaymentFlowError) {
  switch (error.code) {
    case 'session_locked':
      // An earlier attempt holds this checkout. Retrying it cannot work.
      showError(error.message, { label: 'Start a new checkout', href: '/checkout' });
      break;
    case 'load_failed':
      // The browser refused to frame the payment page. Offer it in a new tab.
      showError(
        'The payment form did not load.',
        error.url ? { label: 'Open in a new tab', href: error.url } : undefined
      );
      break;
    case 'unconfirmed':
      // The payment went through but the order is still being confirmed (webhook).
      showMessage('We are confirming your payment. Your order will appear shortly.');
      break;
    case 'unavailable':
      // 503 no_active_payment_provider or PAYMENTS_PAUSED; the BrainerceError is error.cause.
      showError('Payment is not available for this store right now.');
      break;
    default:
      showError('Something went wrong with the payment. Please try again.');
  }
}
presentationWhat the shopper seesWhat you do
widgetThe provider's JavaScript payment windowKeep the container visible, inline.
embeddedA Brainerce-hosted card form (Sola, for example), sized to its contentInline in the checkout. No modal, no overlay.
provider-pageThe provider's own full page inside an iframeWrap the container in a modal. Closing it is not a failure: session.reopen() shows it again.
redirectThe browser leaves for the provider's page (Stripe and PayPal, for example)Nothing. The shopper comes back to /order-confirmation (Step 5.4).
sandboxNothing (the platform's test mode)Show a "Complete test order" button that calls session.completeTestOrder().

The options:

  • container (required): where an iframe or widget is placed. Keep it mounted for the whole step.
  • onSuccess({ checkoutId, orderId?, orderNumber? }): the server has the order. Navigate to /order-confirmation?checkout_id=....
  • onFailure(message): declined or cancelled. The checkout is still valid; let the shopper try again with session.reopen().
  • onError(error): a PaymentFlowError. Switch on error.code: session_locked (offer "Start a new checkout", which goes to /checkout without a checkout_id), total_changed (only when you passed expectedTotal: nothing was charged; show error.totalChange.newTotal and call startPayment again with expectedTotal: newTotal once the buyer accepts), load_failed (offer error.url in a new tab when it is set), unconfirmed (show "confirming, your order will appear shortly"), unavailable (payments not set up or paused), region_required (nothing was charged: the store taxes by state or province and the shipping address has none; ask for it, update the address, and call startPayment again), and blocked_url, blocked_script, provider_error, unsupported (update brainerce), invalid_checkout (show a friendly error).
  • onPresentation(presentation) and onStatus(status) (optional): status is 'preparing' | 'redirecting' | 'open' | 'processing' | 'closed'.
  • providerId (express buttons), saveCard, preferredRenderType, expectedTotal (Step 5.2).
  • urls (optional): override the return pages. Defaults: success /order-confirmation?checkout_id=..., cancel /checkout?checkout_id=...&canceled=true, iframe return /payment-complete?checkout_id=.... When your checkout page loads with canceled=true, show "Payment was canceled" and let the shopper pay again.
  • extraPaymentHosts, extraScriptHosts (optional): extra hosts for a custom self-hosted provider. They add to the platform allowlist and can never remove from it.

The returned PaymentSession has presentation, reopen(), completeTestOrder() and destroy(). Call destroy() when the payment step unmounts. In React, start the session from the click handler, keep the session in a useRef, and return () => sessionRef.current?.destroy() from a useEffect cleanup.

Security is handled inside startPayment. Every payment page URL is checked against the payment page allowlist (PAYMENT_PAGE_HOSTS) and every widget script against the script allowlist (isAllowedPaymentScriptUrl) before the browser touches it. Iframe messages are accepted only from the payment iframe's own window and origin, a completion is handled once, and onSuccess fires only after the server confirms the order. The browser's word that a payment succeeded is never enough.

Content Security Policy. Build your CSP with buildStorefrontCsp({ nonce, apiOrigin }) from brainerce. Its frame-src covers every host in PAYMENT_PAGE_HOSTS plus your store's apiOrigin (Brainerce-hosted card forms are served from it). A hand-written frame-src drifts behind the provider list and the payment iframe renders blank. A custom provider passed in extraPaymentHosts needs its host in your CSP too.

Without the SDK: render by clientSdk.renderType

Only for a storefront that cannot use the npm SDK (Method B). With the SDK, everything in this section is what startPayment already does.

renderArg is the URL, clientSecret is the identifier

Read this before writing the iframe and redirect branches below. Two fields on a payment intent look interchangeable and are not:

FieldWhat it holds
clientSecretThe provider's payment identifier. Nothing guarantees it is a URL.
clientSdk.renderArgThe argument you hand to the render step: the iframe src, the redirect target, or the argument passed to clientSdk.renderMethod. It deliberately overrides clientSecret when both are present.

Some providers duplicate the URL into both fields (PayPal and Sola, for example), which is exactly why reading clientSecret looks right in testing and then fails in production. Other providers return a real identifier in clientSecret, as the contract intends, so a storefront that reads clientSecret hands the shopper a non-URL: the allowed-host check rejects it, the customer never reaches the payment page, and it looks like a provider outage even though the intent was signed correctly and pointed at the right gateway.

Some providers set no renderArg at all (Stripe puts its Checkout URL in clientSecret), so the renderArg || clientSecret fallback keeps their behaviour byte for byte. Always write it in that order, in every branch: the iframe src, the redirect target, the href of any manual "continue to payment" link you render as a fallback, and the argument you pass to clientSdk.renderMethod. Validate the result with isAllowedPaymentUrl(), or navigate with safePaymentRedirect(), before it reaches the browser.

'redirect' (the provider's hosted page: Stripe, PayPal and others)

clientSdk.renderArg is the URL, with clientSecret as the fallback. Navigate the top-level window to it:

import { safePaymentRedirect } from 'brainerce';

if (paymentIntent.clientSdk?.renderType === 'redirect') {
  const paymentUrl = paymentIntent.clientSdk.renderArg || paymentIntent.clientSecret;
  safePaymentRedirect(paymentUrl); // validates the host, then navigates
}

The shopper returns to your successUrl (Step 5.4) or, after cancelling, to your cancelUrl.

'iframe' (a Brainerce-hosted card form or the provider's page in an iframe)

The URL to load in the iframe is clientSdk.renderArg, with clientSecret as the fallback. There are TWO flavors, detected by URL path:

Flavor A, the Brainerce-hosted embed (URL path contains /embed/): a provider in renderMode: 'embedded', or Sola iFields. Render it INLINE in the checkout flow, with NO modal and NO dark overlay. Flavor B, a provider-hosted page (any other URL): a provider in renderMode: 'hosted', or a redirect provider asked for preferredRenderType: 'iframe'. Render it inside a modal overlay.

Listen for these messages, and accept a message only when event.source is the payment iframe's own contentWindow and event.origin is the iframe's origin or your own (the provider page redirects the frame to your /payment-complete):

  • brainerce:resize → set the iframe height to data.height.
  • brainerce:redirect → the embed asks for a top-level navigation (for example a wallet or bank-app handoff). Validate data.url with isAllowedPaymentUrl() first (it checks PAYMENT_PAGE_HOSTS; pass { extraHosts: [...] } for a self-hosted provider).
  • brainerce:payment-complete → you MUST call confirmSdkPayment(checkoutId, data.data), then poll for the order (Step 5.4). Handle it once. For a provider that charges inside the iframe the charge already happened, so this call is a verify. For Sola the card was only tokenized: data.data carries the token payload, and the real charge does not run until this call reaches the backend. Skip it and the checkout is stuck in PAYMENT_PENDING forever (Sola is deliberately excluded from server-side reconciliation, since retrying its confirm without the original tokens would double-charge or fail).
import { isAllowedPaymentUrl } from 'brainerce';

const iframeUrl = paymentIntent.clientSdk.renderArg || paymentIntent.clientSecret;
if (!isAllowedPaymentUrl(iframeUrl)) throw new Error('Payment page not allowed');
const frame = document.createElement('iframe');
frame.src = iframeUrl;
frame.setAttribute('allow', 'payment');
const frameOrigin = new URL(iframeUrl).origin;
let completed = false;

window.addEventListener('message', (e) => {
  if (e.source !== frame.contentWindow) return;
  if (e.origin !== frameOrigin && e.origin !== window.location.origin) return;
  const msg = e.data || {};
  if (msg.type === 'brainerce:resize') frame.style.height = `${Number(msg.height)}px`;
  if (msg.type === 'brainerce:redirect' && isAllowedPaymentUrl(msg.url)) {
    window.location.href = msg.url; // e.g. a wallet or bank-app handoff
  }
  if (msg.type === 'brainerce:payment-complete' && !completed) {
    completed = true;
    client
      .confirmSdkPayment(checkoutId, msg.data)
      .catch(() => {}) // the poll in Step 5.4 is the source of truth
      .finally(() => {
        window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
      });
  }
});

const isBrainerceEmbed = new URL(iframeUrl).pathname.includes('/embed/');
(isBrainerceEmbed ? inlineSlot : modalBody).appendChild(frame);
'sdk-widget' (the provider's JavaScript widget)

The provider's JavaScript widget needs a script loader that waits out its staged start-up ("SDK was not loaded as needed and therefore can't run" and "Wallet not initialized" are transient, not failures). There is no REST-only way to drive it: use the SDK's startPayment, or at least its lower-level mountPaymentWidget (see Payment widgets). Never call the provider's global (clientSdk.globalName) yourself.

'sandbox'

No payment UI. Show a "Complete test order" button that calls POST /checkout/{checkoutId}/complete (Step 5.5).

Step 5.4: Handle the return and verify status

Two small pages on your storefront's own origin finish every flow.

/payment-complete is where a payment iframe lands when the provider page finishes. It calls relayPaymentReturn() and renders nothing else (no header, no layout): inside the frame it tells your checkout page, and opened at the top level it forwards to /order-confirmation.

// /payment-complete
import { relayPaymentReturn } from 'brainerce';

relayPaymentReturn();

/order-confirmation?checkout_id=... is where a redirect provider returns (Stripe adds session_id, PayPal adds token and PayerID, other providers add their own parameters) and where onSuccess sends the shopper. Call completePaymentReturn: it hands the provider's return parameters to the server and waits for the order. The query string alone proves nothing; the server verifies everything.

const checkoutId = new URLSearchParams(window.location.search).get('checkout_id');
if (checkoutId) {
  const result = await client.completePaymentReturn(checkoutId);
  if (result.status === 'succeeded') {
    showOrderSuccess(result.orderNumber); // the order exists; clear the cart (Task 6)
  } else if (result.status === 'pending') {
    showMessage('We are confirming your payment. Your order will appear shortly.');
  } else {
    // 'failed' or 'canceled': the checkout is still valid, let them pay again
    showError('The payment was not completed.', {
      label: 'Back to checkout',
      href: `/checkout?checkout_id=${checkoutId}`,
    });
  }
}

Without the SDK, do the same by hand. On the return, call POST /payment/sdk-confirm with { checkoutId } first (PayPal and other redirect providers do not capture until the server confirms; it is idempotent and safe to skip on failure; the body is checkoutId (up to 64 characters, required) plus optional confirmationNumber (the provider's transaction id, up to 128 characters) and providerResponseData (an object the provider returned; the server treats it as caller-supplied and never trusts it for a security decision), and a deprecated provider-specific alias of the last one is still accepted (do not send it); the route is limited to 5 requests a minute per IP), then poll the payment status:

async function handleOrderConfirmation() {
  const params = new URLSearchParams(window.location.search);
  const checkoutId = params.get('checkout_id');
  if (!checkoutId) {
    showError('Session expired. Please contact support.');
    return;
  }
  if (params.get('canceled') === 'true' || params.get('failed') === 'true') {
    showError('Payment was not completed. Please try again.');
    return;
  }

  try {
    await brainerceAPI('POST', '/payment/sdk-confirm', { checkoutId });
  } catch {
    // Not fatal: the poll below re-verifies server-side.
  }

  // Poll payment status (handles both instant and delayed confirmations)
  for (let attempts = 0; attempts < 10; attempts++) {
    const status = await brainerceAPI('GET', `/checkout/${checkoutId}/payment-status`);

    if (status.status === 'succeeded') {
      if (status.orderId) {
        // Order already created by webhook: show success directly
        showOrderSuccess(status.orderNumber);
      } else {
        // Webhook hasn't fired yet: complete checkout to create the order
        const order = await brainerceAPI('POST', `/checkout/${checkoutId}/complete`);
        showOrderSuccess(order.orderNumber);
      }
      clearAllCheckoutState();
      return;
    }

    if (status.status === 'failed' || status.status === 'canceled') {
      showError('Payment was not completed. Please try again.');
      return;
    }

    // Still pending: wait 2 seconds and try again
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }

  // Timeout after 20 seconds: show a reassuring message
  showMessage('Your payment is being processed. You will receive a confirmation email shortly.');
  clearAllCheckoutState();
}

function clearAllCheckoutState() {
  localStorage.removeItem('brainerce_session');
  localStorage.removeItem('brainerce_cart_id');
  localStorage.removeItem('brainerce_checkout_id');
}

A 400 Payment not yet confirmed (status: processing) from sdk-confirm is not a failure. Some providers answer processing while they are still settling the payment, sometimes for many minutes. The server leaves the payment untouched, so payment-status keeps saying pending until the provider's webhook settles it (it does not flip to failed). Swallow that error as the code above does, keep polling, and never call createPaymentIntent again because of it: a second intent would let the buyer pay twice. Only a failed or canceled status means the buyer should try again.

Payment status endpoint

GET /checkout/{checkoutId}/payment-status

Response:

{
  "status": "succeeded",
  "orderId": "order_abc123",
  "orderNumber": "ORD-00042",
  "paymentIntentId": "pi_abc123"
}
statusMeaningWhat to do
"succeeded"Payment confirmedIf orderId exists → show success. If null → call completeCheckout.
"pending"Still processingWait 2 seconds, poll again (max 10 times)
"failed"Payment failedShow error, let user go back and retry
"canceled"User canceledShow message, let user retry

Step 5.5: Complete checkout

POST /checkout/{checkoutId}/complete

No body needed. Response:

{
  "orderId": "order_abc123",
  "orderNumber": "ORD-00042",
  "status": "pending",
  "total": 499.8,
  "message": "Order created successfully"
}

Common payment errors

HTTPMeaningShow to customer
400Missing address/customer"Please complete all required fields before paying."
400Out of stock"Some items are no longer available. Please review your cart."
402Payment declined"Your payment was declined. Please try a different method."
402code: "PLAN_ORDERS_EXHAUSTED" (from createCheckout() or createPaymentIntent(), never a card decline)The store is at its plan's order cap and opens no checkout. Nothing was charged. Do not show the body's message (it is for the merchant): show "This store is not accepting new orders right now." Branch on err.code; a 402 alone is not a declined card.
410Checkout expired"Your checkout session has expired." Create a new checkout.
500Server error"Something went wrong. Please try again in a moment."
503code: "PAYMENTS_PAUSED"The store's payments are paused (ownership transfer in progress). Show the body's message, keep the cart, do not retry in a loop; only the merchant connecting a payment provider lifts it.
400code: "invalid_expected_total" (createPaymentIntent)A bug in your code, not the buyer's: expectedTotal must be a finite number, 0 or more, at most 1e12, in major units. Send Number(checkout.total) or omit it.
400code: "presentment_currency_unsupported" (createPaymentIntent), with a capability field (dcc-to-base or unsupported)The checkout is priced in a presentment currency that the chosen provider cannot settle (PayPal takes 24 currencies and refuses any other, for example INR or KRW). Nothing was charged. Show the body's message and let the buyer continue in the store's base currency.
400code: "PICKUP_POINT_REQUIRED"The selected shipping rate delivers to a carrier pickup point and none is chosen. Nothing was charged: go back to the pickup point step (Step 4.6b).
400code: "CHECKOUT_CART_CHANGED" (completeCheckout)The cart lines no longer add up to the quoted subtotal. No order was created. Re-read the checkout with getCheckout() (it recomputes the totals), show the new total, then complete again.
409code: "CHECKOUT_OUT_OF_SYNC" (createPaymentIntent)Same cause as CHECKOUT_CART_CHANGED, caught before the charge. Reload the checkout with getCheckout(), show the new total, then pay.
409code: "checkout_claim_failed" (createPaymentIntent)Another request is already creating the payment for this checkout, or it is no longer payable. Do not retry in a loop: read the checkout with getCheckout() and continue from its status.
400code: "unsupported_currency" or "currency_unsupported" (createPaymentIntent)The provider takes one currency (or a short list) and the checkout is in another. Nothing was charged. The message names the currency. Show "This payment method is not available for your order." and let the buyer pick another provider if the store has one.
400code: "missing_return_url" (createPaymentIntent)A redirect provider has nowhere to send the buyer back to because no successUrl was supplied. Nothing was charged. startPayment always sends one; this only happens when you call createPaymentIntent yourself without urls/successUrl.
409code: "provider_not_ready" or "provider_not_configured", or the provider's own codeThe provider refused to open the payment (for example, its onboarding is unfinished or its credentials are missing). The checkout stays payable. Show "Payment is not available right now." and let the buyer try again later.

Task 6: Order confirmation & lookup

Show order confirmation

After successful payment + checkout completion, display:

  • Order number: orderNumber (e.g., "ORD-00042")
  • "Thank you! Your order has been placed."
  • "A confirmation email has been sent to your email address."

Optional, full order details on the confirmation page: if your design calls for more than the order number (line items, shipping address, financial breakdown, the shopper's order note), fetch the full buyer-safe order by checkout id. Works for guests too, because possession of the checkout id is the credential, no login needed:

GET /checkout/{checkoutId}/order

Returns the buyer-safe order shape below (including notes, the shopper's own order note). Render whatever subset fits your design.

Optional: Google Customer Reviews survey. If the merchant has connected a Google Merchant ID and opted in, also call client.getGoogleCustomerReviewsSurvey(checkoutId) here and render Google's opt-in module when enabled is true — see Marketing tags → Google Customer Reviews in the optional integrations doc. This is Google's own verified-buyer survey feeding the seller rating, not Brainerce's product-review feature.

Order status page

Every transactional email links the shopper back to the storefront with the same checkout_id, so the order-status page is the confirmation page's route with a different heading: read the order with GET /checkout/{checkoutId}/order (client.getOrderByCheckout(checkoutId)) and render status, tracking and lines.

A signed-in customer also gets the full history from GET /customers/me/orders (client.getMyOrders()), one page at a time, newest first, same shape per row.

There is no email + order-number lookup on the sales-channel surface. No /api/vc/{salesChannelId} route takes an email address and an order number and returns the order, so do not build a "find my order" form against one; it would only ever 404. A guest reaches their order through the link in their email, and a customer through their account. The one sales-channel route that takes email + order number is POST /orders/guest/downloads, and it returns the order's download links only, never the order.

The older storeId surface does have one: POST /api/stores/{storeId}/orders/lookup with { "email", "orderNumber" } (the order number with or without a leading #) returns the order (id, orderNumber, status, totalAmount, total, currency, items, shippingAddress, createdAt, updatedAt, hasDownloads). A wrong email, a wrong number and an unknown order all answer the same 404, the route is limited to 5 requests a minute per IP, and the SDK has no method for it.

Order response

Money is a decimal string on every field, and status is the uppercase OrderStatus exactly as stored ("PENDING", "PAID", "SHIPPED", ...). totalAmount and total carry the same value.

{
  "id": "order_abc123",
  "orderNumber": "ORD-20260915-0001-D513",
  "status": "PAID",
  "financialStatus": "paid",
  "fulfillmentStatus": "unfulfilled",
  "totalAmount": "529.70",
  "total": "529.70",
  "subtotal": "499.80",
  "shippingAmount": "29.90",
  "taxAmount": "0",
  "taxBreakdown": {
    "subtotal": 449.32,
    "totalTax": 80.38,
    "total": 529.7,
    "pricesIncludeTax": true,
    "breakdown": [{ "name": "VAT", "rate": 18, "amount": 80.38 }]
  },
  "discountAmount": "0",
  "couponCode": null,
  "couponDiscount": null,
  "ruleDiscountAmount": null,
  "appliedDiscounts": null,
  "currency": "ILS",
  "deliveryType": "shipping",
  "paymentMethod": "card",
  "items": [
    {
      "productId": "prod_123",
      "name": "Blue Running Shoes - Size 42 / Blue",
      "sku": "BRS-001-42-BL",
      "quantity": 2,
      "price": "249.90",
      "unitPrice": "249.90",
      "totalPrice": "499.80",
      "image": "https://..."
    }
  ],
  "shippingAddress": {
    "firstName": "John",
    "lastName": "Doe",
    "line1": "Rothschild 123",
    "city": "Tel Aviv",
    "postalCode": "6100000",
    "country": "IL"
  },
  "billingAddress": null,
  "trackingNumber": null,
  "trackingUrl": null,
  "carrier": null,
  "shippedAt": null,
  "deliveredAt": null,
  "tenders": [],
  "hasDownloads": false,
  "notes": null,
  "invoiceUrl": null,
  "invoiceNumber": null,
  "createdAt": "2026-09-15T12:30:00.000Z"
}

taxBreakdown is the one object whose numbers are JSON numbers: it is the frozen tax snapshot, and on a tax-inclusive store it is where the real VAT lives while taxAmount reads "0". tenders lists the gift cards that paid; when it is not empty, what the card was charged is totalAmount minus the tenders' amountBase. invoiceUrl/invoiceNumber are populated only when the connected payment provider issues an invoice document or receipt; both stay null for every provider that does not, and can briefly lag the payment webhook even on one that does.

How to display order status

statusfinancialStatusfulfillmentStatusShow to customer
"PENDING""pending""unfulfilled""Order received, awaiting payment"
"PAID""paid""unfulfilled""Order confirmed, being prepared"
"PROCESSING""paid""unfulfilled""Being prepared"
"SHIPPED""paid""fulfilled""Shipped" + tracking link
"DELIVERED""paid""fulfilled""Delivered"
"COMPLETED""paid""fulfilled""Completed"
"CANCELLED""refunded"any"Cancelled and refunded"
"CANCELLED""pending"any"Cancelled"
"REFUNDED""refunded"any"Refunded"

fulfillmentStatus is "unfulfilled" or "fulfilled"; there is no partial value, so a partly shipped order reads "unfulfilled" until the last shipment.

If trackingUrl is not null → show "Track your order" link.


Task 7: Error handling

API error format

{
  "statusCode": 400,
  "message": "Variant selection is required for this product",
  "error": "Bad Request"
}

Error display rules

ContextHTTPShow to customerLog
Product not found404"This product is no longer available."No
Add to cart: out of stock400"This item is out of stock."Yes
Add to cart: variant missing400"Please select all options before adding to cart."Yes
Coupon invalid400Show API message directly (it's user-friendly)No
Checkout: missing fields400"Please complete all required fields."Yes
Checkout: out of stock400"Some items are no longer available."Yes
Checkout expired410"Your session has expired. Please try again."No
Payment init fails400/500"Payment could not be started. Please try again."Yes
Payment declined402"Payment declined. Try a different method."Yes
Store at its order cap402PLAN_ORDERS_EXHAUSTED from createCheckout() or createPaymentIntent() (a suspended store answers STORE_SUSPENDED, 402 or 403): "This store is not accepting new orders right now." Never the raw messageYes
Login fails401"Invalid email or password."No
Registration: email exists400Show API message directlyNo
Network errornone"Connection error. Check your internet."Yes
Server error500"Something went wrong. Try again shortly."Yes
Token expired401Redirect to loginClear token

Rules:

  • Never show raw API errors for 500 or payment errors
  • Always show API message for: coupon errors, registration validation
  • Always log full errors for debugging

What's next

You now have a fully working store with products, cart, checkout, payment, and orders.

For optional features (customer accounts, social login, promotions, upsells, digital downloads): → Read Optional Features

For validation rules, error codes, edge cases, decision trees, and common mistakes: → Read Rules & Reference


Complete example

const SALES_CHANNEL_ID = 'vc_YOUR_SALES_CHANNEL_ID';
const BASE = `https://api.brainerce.com/api/vc/${SALES_CHANNEL_ID}`;

// Module-scoped, so it lives in memory and dies with the tab. Set it from the
// login response. Never write it to localStorage: any XSS reads it, and it is
// the customer until it expires. To survive a reload, keep it in an HttpOnly
// cookie set by your own server route.
let customerToken = null;

// Same rule as `needsCartProof` in Method B: the routes that take a guest cart's
// proof of ownership (everything under /cart/{id} except /link, POST /checkout,
// the checkout coupon routes and the reservation calls).
function needsCartProof(path) {
  const p = path.split('?')[0];
  if (p === '/checkout' || p === '/reservation/extend' || p === '/reservation/release') return true;
  if (/^\/checkout\/[^/]+\/coupon$/.test(p)) return true;
  const m = /^\/cart\/([^/]+)(\/.*)?$/.exec(p);
  return !!m && !['session', 'merge', 'recover'].includes(m[1]) && m[2] !== '/link';
}

async function api(method, path, body) {
  const opts = { method, headers: { 'Content-Type': 'application/json' } };
  if (customerToken) opts.headers['Authorization'] = `Bearer ${customerToken}`;
  if (body) opts.body = JSON.stringify(body);
  const guestSession = localStorage.getItem('brainerce_session');
  if (guestSession && needsCartProof(path)) {
    path += (path.includes('?') ? '&' : '?') + 'cartToken=' + encodeURIComponent(guestSession);
  }
  const res = await fetch(`${BASE}${path}`, opts);
  if (!res.ok) throw await res.json(); // { statusCode, code, message, ... }: branch on `code`
  return res.json();
}

// 1. SETUP
const caps = await api('GET', '/capabilities');
const currency = caps.store.currency;

// 2. PRODUCTS
const categories = await api('GET', '/categories');
const { data: products } = await api('GET', '/products?limit=12');

// 3. CART (a guest cart's writes carry ?cartToken=<sessionToken>; `api` adds it)
let cart;
const session = localStorage.getItem('brainerce_session');
try {
  cart = session ? await api('GET', `/cart/session/${session}`) : null;
} catch {
  cart = null;
}
if (!cart) {
  cart = await api('POST', '/cart');
  localStorage.setItem('brainerce_session', cart.sessionToken);
  localStorage.setItem('brainerce_cart_id', cart.id);
}

// 4. ADD TO CART
const product = products[0];
const addBody = { productId: product.id, quantity: 1 };
if (product.type === 'VARIABLE' && product.variants?.length > 0) {
  addBody.variantId = product.variants[0].id;
}
cart = await api('POST', `/cart/${cart.id}/items`, addBody);

// 5. CHECKOUT
const checkout = await api('POST', '/checkout', { cartId: cart.id });
localStorage.setItem('brainerce_checkout_id', checkout.id);

await api('PATCH', `/checkout/${checkout.id}/customer`, {
  email: '[email protected]',
  firstName: 'John',
  lastName: 'Doe',
});

if (caps.features.hasShippingZones) {
  await api('PATCH', `/checkout/${checkout.id}/delivery-type`, { deliveryType: 'shipping' });
  const withAddr = await api('PATCH', `/checkout/${checkout.id}/shipping-address`, {
    email: '[email protected]',
    firstName: 'John',
    lastName: 'Doe',
    line1: 'Rothschild 123',
    city: 'Tel Aviv',
    postalCode: '6100000',
    country: 'IL',
    phone: '0501234567',
  });
  if (withAddr.availableShippingRates?.length > 0) {
    await api('PATCH', `/checkout/${checkout.id}/shipping-method`, {
      shippingRateId: withAddr.availableShippingRates[0].id,
    });
  }
}

// 6. PAYMENT
// With the npm SDK this whole step is ONE call, for every provider:
//   const session = client.startPayment(checkout.id, { container, onSuccess, onFailure, onError });
// (Task 5, Step 5.3). Below is the raw REST path for storefronts without the SDK.
const payment = await api('POST', '/payment/intent', { checkoutId: checkout.id });

// Branch on the clientSdk.renderType that came BACK, never on the provider name.
const renderType = payment.clientSdk?.renderType;

if (renderType === 'sandbox') {
  const order = await api('POST', `/checkout/${checkout.id}/complete`);
  console.log(`Order ${order.orderNumber} placed! (sandbox)`);
} else if (renderType === 'redirect') {
  // Stripe, PayPal and other hosted-page providers. renderArg is the URL;
  // clientSecret is only the fallback. Validate the host before navigating.
  window.location.href = payment.clientSdk.renderArg || payment.clientSecret;
  // On return: POST /payment/sdk-confirm once, then poll payment-status (Step 5.4).
} else if (renderType === 'iframe') {
  // Brainerce-hosted card form (Sola) or a provider page in an iframe. '/embed/' in the path → inline,
  // otherwise a modal. Message handling: Step 5.3, "Without the SDK".
} else {
  // 'sdk-widget' (the provider's JavaScript widget) needs the SDK's script loader: use client.startPayment.
}

// 7. CLEANUP
localStorage.removeItem('brainerce_session');
localStorage.removeItem('brainerce_cart_id');
localStorage.removeItem('brainerce_checkout_id');

Checklist

Full checklist with all items: Rules & Reference, Checklist

Quick check for core integration:

Check the cart and checkout behaviour in a real browser. https://brainerce.com/verify-storefront.mjs is a Playwright script that drives any storefront (localhost or live) as a shopper would and reports header navigation without a page reload, adding to the cart in one request, no "empty cart" flash before the real cart arrives, quick taps ending with the right quantity in few requests, a failed update rolling back, a failed load showing an error with retry, checkout waiting for a pending update, floating widgets not covering cart or checkout controls, and autocomplete on the checkout fields. It never submits an order. Run it on a phone viewport before launch: node verify-storefront.mjs --url https://your-shop.example --viewport 390x844 --slow. A starter store has it as npm run verify:storefront -- --url http://localhost:3000.

Products & Navigation

  • Capabilities load correctly
  • Categories display in navigation
  • Products load with images, prices, stock
  • Sale prices: crossed-out original + sale price
  • Out-of-stock: disabled "Add to Cart"
  • Low stock warning shows
  • VARIABLE variant selectors work
  • SIMPLE products add without variantId
  • Product page loads by slug
  • Search suggestions work
  • Pagination works
  • Empty state: "No products found"

Cart

  • Cart creates on first visit
  • Cart loads from localStorage on return
  • 404 creates new cart
  • Add works for SIMPLE and VARIABLE
  • Quantity update works
  • Remove works
  • Empty cart message
  • Coupon apply/remove (if hasCoupons)
  • Totals correct (subtotal, discounts, total)
  • Nudges display
  • Cart badge shows itemCount
  • Cart page shows a skeleton while loading (never the empty message), an error state with a Retry button when loading fails, and the empty message only when the server said so
  • Every internal link uses the framework router link (next/link, SmartLink), never a plain <a href>; navigating from the header does not reload the page
  • A cart write is never followed by a refetch: the returned cart is rendered; quantity changes are optimistic and rapid taps are coalesced (the cart store does this)
  • Checkout is entered only after await cart.flush() returned ok
  • Guest cart writes carry cartToken (automatic with the SDK; your own helper if raw REST) and 403 CART_ACCESS_DENIED does not log the shopper out
  • Floating widgets (popup, chat bubble, cookie banner, accessibility button) are hidden on /cart and /checkout and never cover quantity controls or checkout fields; the primary button under the order total is the checkout button, with a sticky checkout bar on phones
  • On Vercel, the Vercel function region is pinned next to the API (fra1 for Europe), and cartToken is redacted in proxy and function logs

Checkout

  • Checkout creates from cart
  • Checkout inputs carry autocomplete tokens, a name, and inputmode for email and phone
  • Customer email required
  • Shipping flow works (if hasShipping)
  • Pickup flow works (if applicable)
  • Shipping method selection works
  • Order summary correct (uses API total)
  • Gift-card field rendered when features.hasGiftCards (Task 19)
  • Gift card shown below the total plus an "Amount due" line — total untouched, discount block untouched
  • Applied cards read from checkout.tenders so they survive a reload

Payment

  • Payment providers load (express buttons for additive providers)
  • Payment step is one client.startPayment() call, started from a click, with no renderType or provider-name branching
  • provider-page shown in a modal, embedded and widget inline, sandbox shows "Complete test order" (session.completeTestOrder())
  • onError handles session_locked (new checkout), load_failed (open error.url in a new tab), unconfirmed (confirming message)
  • session.destroy() on unmount
  • /payment-complete page calls relayPaymentReturn() and renders nothing else
  • /order-confirmation calls client.completePaymentReturn(checkoutId) and handles succeeded, pending, failed, canceled
  • CSP built with buildStorefrontCsp (covers every payment host)
  • Order number displayed

Post-Purchase

  • localStorage cleared
  • Confirmation email mentioned
  • Order status page reads the order by checkout_id from the email link
  • Order status displays correctly
  • Tracking link shows when available

Errors

  • Product 404 handled
  • Out-of-stock handled
  • Payment failures: user-friendly message
  • Expired checkout: new one created
  • Network errors: retry message
  • 401: token cleared
  • 500: generic message
  • No raw errors shown for payment/500

Contact Inquiries (optional)

Let visitors send you messages from your storefront. Two paths:

  1. Simple (legacy): call createInquiry with { name, email, subject, message } and you're done. This has worked since SDK 1.0 and will keep working forever.
  2. Flexible forms (SDK ≥ 1.21): the merchant configures forms in the dashboard (Customers → Contact Forms): they can hide the default fields, add custom ones (dropdowns, checkboxes, dates, etc.), translate labels per locale, and run multiple named forms (main, newsletter, whatsapp_prechat). You fetch the schema at render time and submit a keyed payload.

Either way, the submission lands in Customers → Inquiries.

A form named newsletter is still an inquiry. The form key is a label, not a behaviour: a submission through it files a message and never touches marketing consent, so the address cannot receive a campaign. For an actual mailing list, meaning the newsletter popup or the footer subscribe bar, use marketing.subscribe() (integration guide → Optional features → Newsletter signup), which creates the contact and runs the confirmed opt-in.

SDK, simple

await brainerce.createInquiry({
  name: 'Jane Doe',
  email: '[email protected]',
  subject: 'Do you ship to Canada?',
  message: 'Hi, I was wondering if you ship to Canada, and how long it usually takes.',
  phone: '+1-555-0100', // optional
  customerId: 'cust_...', // optional — if the visitor is logged in
});
// → { id, status: 'NEW', createdAt }

SDK, flexible forms

The merchant OWNS the form shape (title, fields, labels, order, required flags, validation, translations, success message, submit button). Render from the schema, and do not hardcode labels or field keys in your storefront, or the merchant's dashboard edits won't take effect.

// 1. Fetch the merchant's configured form (localized)
const form = await brainerce.contactForms.get('main', 'he');
// → {
//     id, key, name, description?, submitButton, successMessage,
//     fields: [{ key, type, label, placeholder?, helpText?, isRequired,
//                enumValues?, validation?, defaultValue? }, ...]
//   }

// 2. Render schema.fields dynamically — one branch per type (TEXT, TEXTAREA,
//    EMAIL, PHONE, NUMBER, URL, DATE, SELECT, MULTI_SELECT, CHECKBOX).
//    Use schema.name for the heading, schema.description for the subtitle,
//    schema.submitButton for the submit label, schema.successMessage for the
//    post-submit confirmation.

// 3. Submit the keyed payload
await brainerce.createInquiry({
  formKey: 'main',
  fields: {
    name: 'Jane Doe',
    email: '[email protected]',
    message: 'Hi!',
    // ...any merchant-defined custom keys
  },
  locale: 'he', // inbox filters by language
  sourceMetadata: { page: '/products/42' }, // optional — arbitrary provenance
});

Full dynamic rendering example (React)

import { useEffect, useState } from 'react';
import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';

type FieldValue = string | string[] | boolean;

function defaultValueFor(f: ContactFormPublicField): FieldValue {
  if (f.type === 'CHECKBOX') return false;
  if (f.type === 'MULTI_SELECT') return [];
  return f.defaultValue ?? '';
}

function isEmpty(v: FieldValue) {
  if (typeof v === 'string') return v.trim().length === 0;
  if (Array.isArray(v)) return v.length === 0;
  return v === false;
}

function DynamicField({
  field,
  value,
  onChange,
}: {
  field: ContactFormPublicField;
  value: FieldValue;
  onChange: (v: FieldValue) => void;
}) {
  const id = `contact-${field.key}`;
  const { minLength, maxLength, min, max, pattern } = field.validation ?? {};
  const s = typeof value === 'string' ? value : '';
  const label = (
    <label htmlFor={id}>
      {field.label}
      {field.isRequired && ' *'}
    </label>
  );
  const help = field.helpText ? <p>{field.helpText}</p> : null;

  switch (field.type) {
    case 'TEXTAREA':
      return (
        <div>
          {label}
          <textarea
            id={id}
            required={field.isRequired}
            maxLength={maxLength}
            minLength={minLength}
            rows={6}
            placeholder={field.placeholder}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
    case 'EMAIL':
      return (
        <div>
          {label}
          <input
            id={id}
            type="email"
            required={field.isRequired}
            autoComplete="email"
            placeholder={field.placeholder}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
    case 'PHONE':
      return (
        <div>
          {label}
          <input
            id={id}
            type="tel"
            required={field.isRequired}
            autoComplete="tel"
            placeholder={field.placeholder}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
    case 'URL':
      return (
        <div>
          {label}
          <input
            id={id}
            type="url"
            required={field.isRequired}
            placeholder={field.placeholder}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
    case 'NUMBER':
      return (
        <div>
          {label}
          <input
            id={id}
            type="number"
            required={field.isRequired}
            min={min}
            max={max}
            placeholder={field.placeholder}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
    case 'DATE':
      return (
        <div>
          {label}
          <input
            id={id}
            type="date"
            required={field.isRequired}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
    case 'SELECT':
      return (
        <div>
          {label}
          <select
            id={id}
            required={field.isRequired}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          >
            <option value="">—</option>
            {field.enumValues?.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
          {help}
        </div>
      );
    case 'MULTI_SELECT': {
      const arr = Array.isArray(value) ? value : [];
      return (
        <div>
          {label}
          {field.enumValues?.map((o) => (
            <label key={o.value}>
              <input
                type="checkbox"
                checked={arr.includes(o.value)}
                onChange={(e) =>
                  onChange(e.target.checked ? [...arr, o.value] : arr.filter((v) => v !== o.value))
                }
              />
              {o.label}
            </label>
          ))}
          {help}
        </div>
      );
    }
    case 'CHECKBOX':
      return (
        <div>
          <label htmlFor={id}>
            <input
              id={id}
              type="checkbox"
              required={field.isRequired}
              checked={value === true}
              onChange={(e) => onChange(e.target.checked)}
            />
            {field.label}
          </label>
          {help}
        </div>
      );
    case 'TEXT':
    default:
      return (
        <div>
          {label}
          <input
            id={id}
            type="text"
            required={field.isRequired}
            maxLength={maxLength}
            minLength={minLength}
            pattern={pattern}
            placeholder={field.placeholder}
            value={s}
            onChange={(e) => onChange(e.target.value)}
          />
          {help}
        </div>
      );
  }
}

export function ContactPage() {
  const [schema, setSchema] = useState<ContactFormPublic | null>(null);
  const [values, setValues] = useState<Record<string, FieldValue>>({});
  const [honeypot, setHoneypot] = useState('');
  const [sent, setSent] = useState(false);

  const locale =
    typeof document !== 'undefined' ? document.documentElement.lang || undefined : undefined;

  useEffect(() => {
    brainerce.contactForms.get('main', locale).then((form) => {
      setSchema(form);
      const initial: Record<string, FieldValue> = {};
      for (const f of form.fields) initial[f.key] = defaultValueFor(f);
      setValues(initial);
    });
  }, [locale]);

  if (!schema) return null;
  if (sent) return <div>{schema.successMessage}</div>;

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        if (honeypot.trim().length > 0) {
          setSent(true);
          return;
        }
        const payload: Record<string, unknown> = {};
        for (const f of schema.fields) {
          const raw = values[f.key];
          if (isEmpty(raw)) continue;
          payload[f.key] = typeof raw === 'string' ? raw.trim() : raw;
        }
        await brainerce.createInquiry({ formKey: schema.key, fields: payload, locale });
        setSent(true);
      }}
    >
      <h1>{schema.name}</h1>
      {schema.description && <p>{schema.description}</p>}

      {/* honeypot — hide from humans, bots will fill it */}
      <div
        aria-hidden
        style={{ position: 'absolute', left: '-10000px', width: 0, height: 0, overflow: 'hidden' }}
      >
        <input
          name="honeypot"
          tabIndex={-1}
          autoComplete="off"
          value={honeypot}
          onChange={(e) => setHoneypot(e.target.value)}
        />
      </div>

      {schema.fields.map((field) => (
        <DynamicField
          key={field.key}
          field={field}
          value={values[field.key] ?? defaultValueFor(field)}
          onChange={(v) => setValues((p) => ({ ...p, [field.key]: v }))}
        />
      ))}

      <button type="submit">{schema.submitButton}</button>
    </form>
  );
}

List all active forms when your site has more than one (e.g. footer vs. landing page vs. popup):

const forms = await brainerce.contactForms.list();
// → [{ key: 'main', name: 'Main Contact', isDefault: true }, { key: 'newsletter', ... }]

REST

# Schema
GET {baseUrl}/stores/{storeId}/contact-forms
GET {baseUrl}/stores/{storeId}/contact-forms/{formKey}?locale=he

# Submission (both shapes accepted on the same endpoint)
POST {baseUrl}/stores/{storeId}/inquiries
Content-Type: application/json
Origin: https://your-storefront.com

# Legacy:
{ "name": "Jane", "email": "[email protected]", "subject": "...", "message": "..." }

# Or Phase 2:
{ "formKey": "main", "fields": { "email": "...", "message": "..." }, "locale": "he" }

Rules

  • Rate-limited to 3 requests per 60 seconds per IP. Debounce form submissions.
  • Include a honeypot input (e.g. <input name="honeypot" style="display:none" />) and do not send it. Any request that includes a non-empty honeypot field is rejected as a bot.
  • Unknown keys inside fields are silently stripped by the server; every value is validated against the form's schema. Max value length 10 000 chars.
  • After success, clear the form and show a thank-you state. Do NOT expose the returned id to the visitor.
  • The schema GET is cacheable per {storeId, formKey, locale}, and 60 s is usually enough to feel live while surviving traffic spikes.

For customer accounts, OAuth, and optional features checklist → see Rules & Reference

Product Reviews

Customer-submitted reviews powering trust signals and Google rich snippets (★ stars in search). Reviews publish immediately, and there is no PENDING moderation queue.

Who can review: only authenticated customers who purchased the product. Guests cannot submit. Each customer can write one review per product, and can edit or delete it afterwards.

Read reviews on a PDP (no login needed)

Check getStoreInfo().reviewsEnabled first and render nothing while it is false (absent means on). With reviews switched off, listProductReviews throws a 403 with details.code === 'REVIEWS_DISABLED'; see Product Reviews rules.

const { data, meta } = await client.listProductReviews('prod_123', { page: 1, limit: 20 });

data.forEach((review) => {
  console.log(review.authorName, review.rating, review.body, review.verifiedPurchase);
  // Photos the author attached, already filtered to what shoppers may see.
  // Always an array — never null — so you can map it without a guard.
  review.images.forEach((img) => console.log(img.thumbnailUrl ?? img.url));
});

Reviews carrying photos come back first by default (sort: 'photos_first'), newest-first within each group. Pass sort: 'newest' for plain chronological order. On a store with no review photos the two orderings are identical.

Set width and height on your <img> from img.width / img.height so the gallery reserves space instead of shifting the page as photos load.

Each Product object already carries denormalized rollups:

product.avgRating; // 4.5
product.reviewCount; // 23

Use these for star displays on PLP cards and to gate JSON-LD emission.

Render the right form state for the current customer

client.setCustomerToken(authToken); // after login

const { eligible, reason, myReview, photos, myImages } =
  await client.getMyProductReview('prod_123');

if (!eligible) {
  // reason: 'no_eligible_order' | 'reviews_disabled' | 'product_not_found'
  // Show a friendly "only purchasers can review" message.
} else if (myReview) {
  // Show edit form prefilled with myReview.rating + myReview.body.
} else {
  // Show submit form (rating + body only — no name/email).
}

// `photos` is the store's live policy. Read it instead of hard-coding limits:
// photos.enabled          → render a file picker at all
// photos.maxPerReview     → how many files to accept
// photos.maxBytes         → reject oversized files before uploading
// photos.requiresApproval → tell the customer their photo waits for the merchant

Submit, edit, delete

Author name + email are derived server-side from the customer profile, so you only send rating and optional body.

// Create
await client.submitProductReview('prod_123', { rating: 5, body: 'Loved it!' });

// Edit (rating + body)
await client.updateMyProductReview('prod_123', { rating: 4, body: 'On reflection, 4 stars.' });

// Delete — after this the customer can submit again
await client.deleteMyProductReview('prod_123');

Attach photos

Upload each file first, then pass the returned keys (not URLs) on submit.

const { photos } = await client.getMyProductReview('prod_123');

if (photos.enabled) {
  const uploads = await Promise.all(
    [...fileInput.files]
      .slice(0, photos.maxPerReview)
      .map((f) => client.uploadReviewPhoto('prod_123', f))
  );

  await client.submitProductReview('prod_123', {
    rating: 5,
    body: 'Arrived beautifully wrapped.',
    imageKeys: uploads.map((u) => u.key), // u.url is for a local preview only
  });
}

uploadReviewPhoto requires the same customer token and the same purchase eligibility as writing the review, checked before the file is stored so an ineligible shopper is told straight away rather than after a 5MB upload.

On update, imageKeys replaces the photo set, so send the keys you want to keep. Omitting the field entirely leaves the existing photos alone, so a PATCH that only changes the rating will not silently strip them. Passing [] removes them all.

On a store with photo approval turned on, the submit/update response shows no photos. Its images array carries only what shoppers can see, and a pending photo is not that yet. Call getMyProductReview() afterwards and render myImages, which includes pending ones, or the customer will think their upload failed.

What the server does to each file: JPEG/PNG/WebP/GIF only, cross-checked against the actual bytes rather than the declared type; 5MB and 40 megapixels max; converted to WebP. EXIF is stripped, so a customer's GPS coordinates never reach your storefront, but the orientation tag is applied first, so portrait photos from a phone stay upright. A photo uploaded and never attached to a submitted review is reclaimed after 7 days.

Errors:

  • 403 Forbidden with reason: 'no_eligible_order': customer didn't purchase the product.
  • 403 Forbidden: store has reviews or review photos switched off.
  • 409 Conflict: customer already has a review; use updateMyProductReview instead.
  • 400 Bad Request on imageKeys: a key that is not a review photo, is not yours, already belongs to another review, or pushes the review past its photo cap.
  • 429 Too Many Requests: throttled (3 submits / 60s / IP, 5 edits/deletes / 60s / IP, 10 photo uploads / 60s).

Eligibility rules:

  • Physical products → an order in status SHIPPED / COMPLETED / DELIVERED.
  • Downloadable products (isDownloadable: true) → additionally PAID / PROCESSING (no waiting for shipping).

JSON-LD (SEO)

Emit aggregateRating and a few sample reviews only when reviewCount > 0 and getStoreInfo().reviewsEnabled !== false (a product keeps its stored rating while reviews are off):

const productJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: product.name,
  // ...
  ...(product.reviewCount > 0 && {
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: product.avgRating,
      reviewCount: product.reviewCount,
      bestRating: 5,
      worstRating: 1,
    },
  }),
};

Stores with no reviews simply don't claim the field, which silences the Google Search Console "missing field" warning without faking data.

Admin-side moderation (adminListProductReviews, hideProductReview, showProductReview) is documented in [Optional Features](./Optional Features).

SDK JSON-LD builders, preferred over hand-rolled objects

The SDK ships schema.org builders that encode Google's structured-data rules (aggregateRating gating, AggregateOffer for VARIABLE products, ISO-4217 currency, XSS-safe serialization). buildProductJsonLd's Offer also always includes itemCondition (hardcoded NewCondition), priceValidUntil when the product has an active sale-price window (salePriceEndsAt), and shippingDetails when you pass shipping (real flat-rate/free zones from storeInfo.shipping, omitted entirely and never fabricated if you don't pass it). Use these builders instead of hand-rolling:

import {
  buildProductJsonLd,
  buildArticleJsonLd,
  buildOrganizationJsonLd,
  buildCollectionPageJsonLd,
  buildBreadcrumbJsonLd,
  jsonLdScriptProps,
} from 'brainerce';

// `siteUrl` must be the storefront's PUBLIC origin. Do not read it straight
// from an env var with a hardcoded default: the value is frequently unknown
// until runtime (an AI builder deploys to a domain it owns, a container host
// only learns its hostname from the request), and a wrong absolute URL in
// JSON-LD is indistinguishable from a correct one to a crawler. Resolve it —
// explicit env, then hosting-platform vars, then the request's forwarded host.
// Scaffolded storefronts ship exactly that as `getCanonicalSiteUrl()` in
// `src/core/lib/site-url.ts`; the same order applies if you write your own.
const siteUrl = await getCanonicalSiteUrl();

// Product page (PDPs ONLY — never on listing pages)
<script {...jsonLdScriptProps(buildProductJsonLd(product, {
  siteUrl,
  path: `/products/${product.slug}`,
  currency: storeInfo.currency,
  shipping: storeInfo.shipping, // optional — adds shippingDetails when present
}))} />

// Blog article page
<script {...jsonLdScriptProps(buildArticleJsonLd(post, {
  siteUrl,
  path: `/blog/${post.slug}`,
  organizationName: storeInfo.name,
}))} />

// Homepage
<script {...jsonLdScriptProps(buildOrganizationJsonLd(storeInfo, {
  siteUrl,
}))} />

jsonLdScriptProps() escapes < in the serialized JSON so merchant-controlled fields can never break out of the script element.

SEO & Discoverability (REQUIRED)

Brainerce's SEO Autopilot writes and publishes blog posts automatically. Your storefront must make the whole catalog + that content discoverable:

1. Product + category + blog entries in sitemap.xml. Use the SDK helpers. ⚠️ Products must go through getProductSitemapEntries: the public listing API clamps limit to 100, so a naive getProducts({ limit: 1000 }) sitemap silently truncates at 100 products. The helper uses a dedicated lightweight endpoint (slug + updatedAt only, up to 5000 in one call) and falls back to pagination on older backends:

// app/sitemap.ts
import {
  getProductSitemapEntries,
  getCategorySitemapEntries,
  getBlogSitemapEntries,
} from 'brainerce';

const productPages = await getProductSitemapEntries(client, {
  siteUrl: baseUrl,
  locales: supportedLocales, // optional (multi-locale stores)
  defaultLocale,
}).catch(() => []);
const categoryPages = await getCategorySitemapEntries(client, {
  siteUrl: baseUrl,
  locales: supportedLocales,
  defaultLocale,
}).catch(() => []);
const blogPages = await getBlogSitemapEntries(client, {
  siteUrl: baseUrl,
  locales: supportedLocales,
  defaultLocale,
}).catch(() => []);
return [...staticPages, ...productPages, ...categoryPages, ...blogPages];

Under the hood getProductSitemapEntries calls client.getSitemapProducts(limit = 5000), which returns bare { id, slug, updatedAt, localeSlugs } rows from a dedicated endpoint with no 100-per-page clamp. Call it directly only if you are building the XML yourself; it is sales-channel mode only and throws in storeId or admin mode, which is precisely the throw the helper catches when it falls back to paginating getProducts. Prefer the helper.

1b. robots.txt allows the AI search crawlers by name: OAI-SearchBot, ChatGPT-User, Claude-SearchBot, Claude-User, PerplexityBot, Perplexity-User, Bingbot, Applebot, Amazonbot. These agents power ChatGPT/Claude/Perplexity/Copilot shopping answers and read raw HTML only; blocking them makes the store invisible to AI assistants. Keep /api/, /auth/, /checkout/, /account/ disallowed for every agent. See the scaffold's app/robots.ts.

2. IndexNow key file at /indexnow-key.txt. The platform pings IndexNow (instant search-engine indexing) whenever a post publishes, and search engines verify ownership by fetching this file. Serve it exactly like this:

// app/indexnow-key.txt/route.ts
import { getServerClient } from '@/core/lib/brainerce';

export const revalidate = 3600;

export async function GET() {
  const info = await getServerClient()
    .getStoreInfo()
    .catch(() => null);
  const key = info?.seo?.indexNowKey;
  if (!key) return new Response(null, { status: 404 });
  return new Response(key, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}

The key is exposed on getStoreInfo().seo.indexNowKey (sales-channel mode). It is not a secret, because the file is public by protocol design. Returning 404 while it's null is correct.

3. /llms.txt + /agents.md: two AI-discovery files, same data, two conventions. /llms.txt is the machine-readable site summary AI answer engines read (store name + description, category links, key pages, recent article URLs); /agents.md is the agent-facing guide (what the site sells, machine surfaces, key URLs, currency, how buying works). See the scaffold's app/llms.txt/route.ts and app/agents.md/route.ts for the reference implementations (revalidate: 3600). Multi-locale stores: these dotted routes (plus indexnow-key.txt) must live at the app ROOT, never inside [locale]/, because locale middleware matchers skip dotted paths, so a locale-nested copy resolves as the homepage and serves HTML instead of the file. The scaffold's agents.md route also lists the public Storefront MCP endpoint, a per-channel POST /api/mcp/storefront/{salesChannelId} JSON-RPC surface exposing search_products / get_product / list_categories / get_store_info tools for AI shopping agents. It works the same way whether or not you're using the scaffold; see the standalone Storefront MCP reference for the endpoint shape, tool schemas, and rate limits.

4. Google site-verification meta tag. When getStoreInfo().seo.googleSiteVerification is set (the merchant pastes their Search Console token in channel settings), render it in the root layout <head>:

{
  storeInfo?.seo?.googleSiteVerification ? (
    <meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
  ) : null;
}

This is what lets the merchant verify the domain in Search Console and claim the website in Merchant Center (the Google app's onboarding depends on it).

5. Renamed slugs 301 instead of 404. The platform records every product/blog slug rename. In the not-found path of the product and blog pages, resolve before giving up:

// app/products/[slug]/page.tsx
import { notFound, permanentRedirect } from 'next/navigation';

let product;
try {
  product = await client.getProductBySlug(slug);
} catch {
  const redirect = await client.resolveSlugRedirect('product', slug); // 'blog' for blog posts
  if (redirect) permanentRedirect(`/products/${redirect.currentSlug}`);
  notFound();
}

resolveSlugRedirect returns null when no rename was recorded (a genuine 404) and never throws, so it is safe to call unconditionally in the catch path. Rename chains (a→b→c) collapse to one hop.

Blog rendering itself (/blog + /blog/[slug] via client.blog.getPosts() / getPost(slug)) is documented in the Blog section. Those pages are required for any store with published posts.

6. Google cart transfer profile at /.well-known/ucp. When the merchant turns on Google cart transfer for the sales channel (dashboard: Apps → Google), Google reads a profile document from the store's own domain to learn where to send a shopper's cart. getStoreInfo().ucp?.profile (type UcpProfile, brainerce 3.13.0 and later, sales-channel mode only) is that document. Serve it verbatim and answer 404 while it is absent:

// app/.well-known/ucp/route.ts
import { getServerClient } from '@/core/lib/brainerce.server';

export const revalidate = 300;

export async function GET() {
  let info: unknown;
  try {
    info = await (await getServerClient()).getStoreInfo();
  } catch {
    return new Response(null, { status: 503, headers: { 'Retry-After': '30' } }); // could not ask, not "off"
  }
  const profile = (info as { ucp?: { profile?: unknown } } | null)?.ucp?.profile;
  if (!profile || typeof profile !== 'object') return new Response(null, { status: 404 });
  return new Response(JSON.stringify(profile), {
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'public, max-age=300',
    },
  });
}

This is a storefront file, not part of the SDK: upgrading brainerce does not add it to an existing store. A store made with npx create-brainerce-store gets it once the template that carries it is released; an older or hand-built store must add the file. Do not edit or add to the profile (Google requires it to match what the merchant selected in Merchant Center), and do not put it in the data serialised into your pages. It must answer application/json over HTTPS with Cache-Control: public and max-age of at least 60, and must not redirect, so both the www and the bare domain have to answer directly. Multi-locale stores keep this dotted route at the app ROOT, like indexnow-key.txt. The shopper's cart then arrives as /cart?brainerce_cart=...#brainerce_recover=..., which the SDK adopts like an abandoned-checkout link. Full reference, including what is still unconfirmed with Google: Google cart transfer (UCP).

Content: typed merchant content store

Brainerce ships a typed content store so merchants can edit FAQ, footer, header, announcement banners, rich-text blocks, and static pages in the dashboard, without re-prompting the AI that built their storefront. The merchant edits content in Sell → Content; storefronts pick up changes within ~5 minutes (public reads carry Cache-Control: public, max-age=300, stale-while-revalidate=60).

Six content types, each with a fixed data shape:

TypeUse fordata shape
FAQQ/A accordions{ items: { question, answer }[] }
FOOTERSite footer (chrome){ columns, copyright?, social? }
HEADERTop nav + logo + CTA (chrome){ logo?, navItems, cta? }
ANNOUNCEMENTBanners; time-bound by startsAt/endsAt{ message, severity, dismissible, startsAt?, endsAt?, ctaLabel?, ctaHref? }
RICH_TEXTInline HTML blocks{ html }
PAGEStatic pages with slug + SEO{ slug, title, html, seo? }

Default key

Every type has 'main' as its universal default key. client.content.faq.get() with no arguments resolves to key='main'. Topical keys ('shipping', 'holiday-2026', 'about') are merchant-named.

Reading content (public, any SDK mode)

// Fetch one entry, optionally locale-resolved server-side
const faq = await brainerce.content.faq.get('main', 'he');
if (faq) {
  faq.data.items.forEach(({ question, answer }) => {
    // sanitize(answer) before injecting via dangerouslySetInnerHTML — see Security
  });
}

// All entries of a type
const allFaqs = await brainerce.content.faq.list('he');

// Page by URL slug — for app/[slug]/page.tsx catch-all routes
const page = await brainerce.content.page.getBySlug('about', 'he');
if (!page) notFound();

All get / getBySlug calls return null on 404. Storefronts should render a hard-coded fallback when null so the page never crashes when the merchant hasn't seeded content yet.

Default content and the store's language

Every new store is seeded with a main HEADER (nav: Shop / Blog / Contact), FOOTER and FAQ, written in the store's language at creation. Ten languages have copy (en, he, ar, es, fr, de, ja, zh, ru, pt); any other code gets English.

  • Changing the store's language re-writes these three entries into the new language, but only while they are still exactly the seeded text. A header with an edited label, a logo, a call to action or an extra link, a renamed entry, or a FAQ the merchant wrote is never touched.
  • Stores created before this behaviour can still hold the wrong language (for example an English header on a Hebrew store). It is not a storefront bug and cannot be fixed from the storefront: a vc_ connection reads content but cannot write it. Do not hardcode translated labels in the storefront either, because that overrides whatever the merchant edits later. Ask the merchant to edit the entries in Sell → Content (Header, Footer, FAQ), or use update_content when an admin connector is attached.

Security: sanitize HTML before rendering

FAQ.items[i].answer, RICH_TEXT.html, and PAGE.html contain merchant-authored HTML. The server does NOT pre-sanitize because some merchants embed iframes (e.g. YouTube). ALWAYS sanitize on the storefront before injecting:

import DOMPurify from 'isomorphic-dompurify';
const safe = DOMPurify.sanitize(rawHtml);
<div dangerouslySetInnerHTML={{ __html: safe }} />;

Skipping this is XSS. The scaffold ships a sanitizeHtml() helper at src/core/lib/sanitize.ts (imported as @/core/lib/sanitize), so use it — there is no src/lib/ in the scaffold.

Product descriptions can contain video and embeds

Product.description is merchant-authored HTML too, and may now include self-hosted <video> (uploaded to the media library) and host-locked YouTube / Vimeo <iframe> embeds. It IS server-sanitized on write, but your storefront still sanitizes on render, so make sure your sanitizer keeps them:

  • Allow video / source tags plus playback attributes (controls, poster, preload, muted, loop, playsinline, type).
  • Allow iframe only for an embed host allowlist (www.youtube.com, www.youtube-nocookie.com, player.vimeo.com), never arbitrary iframes. The scaffold's sanitizeProductHtml() at src/core/lib/sanitize-html.ts (imported as @/core/lib/sanitize-html) already does this — again, there is no src/lib/ in the scaffold.
  • If you set a Content-Security-Policy, add those three hosts to frame-src, or the embed renders blank even after sanitizing.

Sizing lives on the <iframe> / <video> width/height attributes (inline style is stripped server-side), so they render at a real size without any of your CSS.

Site chrome: fetch in root layout

// app/layout.tsx (Server Component)
const [header, footer, announcements] = await Promise.all([
  brainerce.content.header.get('main', locale),
  brainerce.content.footer.get('main', locale),
  brainerce.content.announcement.list(locale),
]);

return (
  <html lang={locale} dir={brainerce.getStoreDirection(locale)}>
    <body>
      <AnnouncementBar announcements={announcements} />
      <SiteHeader header={header} />
      {children}
      <SiteFooter footer={footer} />
    </body>
  </html>
);

Announcements are time-bounded by startsAt / endsAt ISO timestamps, so filter client-side so the cached server response can be shared across visitors regardless of the current time.

Custom fields

Every Content row carries customFields: Record<string, string> for merchant-defined extras (helpEmail, phoneNumber, foundedYear). Read keys the merchant told you to expect:

const faq = await brainerce.content.faq.get('shipping');
<a href={`mailto:${faq.customFields.helpEmail}`}>Need help?</a>

Translations

All public reads accept a locale argument. The server resolves translations[locale] server-side with deep-merge + empty-string fallthrough, so unedited overlay fields fall back to the default-locale value naturally. Your storefront does NOT need to do its own overlay. Call with the active locale and render what comes back.

RTL direction

const dir = brainerce.getStoreDirection(locale); // 'ltr' | 'rtl'
<html lang={locale} dir={dir}>
  ...
</html>;

Covers Arabic, Hebrew, Persian, Urdu, Yiddish today and picks up any future RTL locale the platform adds. Do NOT maintain a local RTL locale set.

Admin writes (apiKey mode only)

Admin operations are available when you instantiate the client with an API key (brainerce_*); calling them from storefront / sales-channel mode throws.

Every admin call takes an explicit storeId as its last argument. Admin mode has no ambient store (storeId is only set in storefront mode) and the admin content routes are store-scoped, so the SDK sends it as a query param rather than a body field. Omit it and the store scope guard rejects the call fail-closed with 403 STORE_SCOPE_REQUIRED before the handler runs, so do not expect a 400. Pass the id of the store your API key is bound to; naming any other store is rejected as cross-tenant. The key needs the content:read scope for the admin reads and content:write for the writes.

The public reads above (get, list, getBySlug) are storefront APIs and throw in admin mode, because the admin API has no by-key or by-slug read and its list route is store-scoped. Read with the admin pair instead.

const adminClient = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });
const storeId = process.env.BRAINERCE_STORE_ID!;

// Read (drafts included). listAdmin takes one filter object; storeId is required in it.
const rows = await adminClient.content.listAdmin({ storeId, type: 'FAQ', status: 'DRAFT' });
const row = await adminClient.content.findById('cnt_123', storeId); // throws on 404

// Create — always lands in DRAFT
const faq = await adminClient.content.faq.create(
  {
    key: 'shipping',
    name: 'Shipping FAQ',
    data: { items: [{ question: 'How long?', answer: 'Most orders ship in 2 days.' }] },
  },
  storeId
);

// Update (replaces `data` wholesale — last-write-wins)
await adminClient.content.update(
  faq.id,
  { data: { items: [{ question: 'How long?', answer: 'Updated answer.' }] } },
  storeId
);

// Publish / unpublish
await adminClient.content.publish(faq.id, storeId);
await adminClient.content.unpublish(faq.id, storeId);

// Hard delete (irreversible — prefer unpublish for soft-removal)
await adminClient.content.remove(faq.id, storeId);

REST equivalents

MethodPathReturns
GET/api/stores/:storeId/content?type=FAQContent[]
GET/api/stores/:storeId/content/:type/:keyContent or 404
GET/api/stores/:storeId/content/pages/by-slug/:slugContent<'PAGE'> or 404
GET/api/vc/:connectionId/content/...Same shapes, channel-scoped
GET/api/content?storeId=...&type=FAQ (admin)Content[]
GET/api/content/:id?storeId=... (admin)Content
POST/api/content?storeId=... (admin)Content
PATCH/api/content/:id?storeId=... (admin)Content
POST/api/content/:id/publish?storeId=... (admin)Content
POST/api/content/:id/unpublish?storeId=... (admin)Content
DELETE/api/content/:id?storeId=... (admin)204

There is no /api/v1/content. The admin content controller is mounted at /api/content, and the v1 spelling 404s on every call.

Blog

Merchants write blog posts in Content → Blog in the dashboard. Storefronts choose their own URL scheme, so render posts at /blog/[slug], /articles/[slug], or whatever fits the brand.

Public reads carry Cache-Control: public, max-age=300, stale-while-revalidate=60.

Scheduling: A post is visible once status === 'PUBLISHED' and publishedAt <= now(). Set a future publishedAt while publishing to schedule. No cron is needed; visibility is computed at query time.

SDK example

import { BrainerceClient } from 'brainerce';
import DOMPurify from 'isomorphic-dompurify';

const brainerce = new BrainerceClient({ salesChannelId: 'vc_live_xxx' });

// List published posts (paginated)
const { data: posts, meta } = await brainerce.blog.getPosts({ page: 1, limit: 10 });

// Filter by category or tag
const { data: news } = await brainerce.blog.getPosts({ category: 'news' });
const { data: tips } = await brainerce.blog.getPosts({ tag: 'tutorial' });

// Single post by slug (returns null on 404)
const post = await brainerce.blog.getPost('my-first-post');
if (post) {
  // Always sanitize HTML content before rendering via dangerouslySetInnerHTML
  const safeContent = DOMPurify.sanitize(post.content);
}

Rendering the post body: content is HTML from the Lexical editor. Always pass it through DOMPurify.sanitize() before using dangerouslySetInnerHTML. Even though content originates from your own dashboard, sanitizing prevents any stored-XSS path if content is ever imported or migrated from an external source.

// app/blog/[slug]/page.tsx — Next.js App Router example
import DOMPurify from 'isomorphic-dompurify';

const post = await brainerce.blog.getPost(params.slug);
if (!post) notFound();

const safeHtml = DOMPurify.sanitize(post.content);
return <div dangerouslySetInnerHTML={{ __html: safeHtml }} className="prose" />;

Each BlogPost object has:

FieldTypeNotes
idstring
titlestring
slugstringUnique per store, URL-safe
categorystring?Free-form label (e.g. 'news', 'guides')
contentstringHTML from the rich-text editor
excerptstring?Short summary for listings
coverImageUrlstring?
authorstring?
tagsstring[]
publishedAtstring?ISO 8601
seoTitlestring?
seoDescriptionstring?
ogImageUrlstring?
translationsRecord<string, Record<string, string>>?Per-locale overrides for title/excerpt/content/seoTitle/seoDescription/slug. title/excerpt/content/seoTitle/seoDescription/slug above are already resolved to the active locale (call client.setLocale() first), so read this map directly only if you need the raw per-locale data.

Translated slugs are not routable. getPost(slug) matches the post's base (default-locale) slug only. When setLocale() is set to another locale and the post's translation carries its own slug, the list read returns that translated value in slug, and passing it back to getPost() is a 404. Until that is fixed, build post links from the base slug: read the list with the store's default locale (or keep a second client without setLocale()) and use its slug, or leave the slug field out of blog translations in the dashboard so slug stays the base value in every locale.

Admin writes (apiKey mode only)

Every admin call takes an explicit storeId as its last argument, on the same terms as Content above: admin mode has no ambient store, the SDK sends storeId as a query param, and a missing or foreign one is rejected fail-closed with 403 STORE_SCOPE_REQUIRED before the handler runs. The key needs blog:read for the reads and blog:write for the writes.

Admin lookups are by id, not by slug. getPost(slug) is a storefront read and throws in admin mode, because the admin route is GET /api/blog/posts/:id.

const adminClient = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });
const storeId = process.env.BRAINERCE_STORE_ID!;

// Read (drafts included)
const { data, meta } = await adminClient.blog.getPosts({ page: 1, limit: 10 }, storeId);
const post = await adminClient.blog.findById('post_123', storeId); // null on 404

// Write
const draft = await adminClient.blog.create({ title: 'Hello World' }, storeId);
await adminClient.blog.update(draft.id, { title: 'Renamed' }, storeId);
await adminClient.blog.publish(draft.id, storeId);
await adminClient.blog.unpublish(draft.id, storeId);
const copy = await adminClient.blog.duplicate(draft.id, storeId); // DRAFT "<title> (Copy)", new slug
await adminClient.blog.remove(draft.id, storeId);

blog.findById resolves to null on 404. content.findById throws on 404. They read alike and behave differently, so check which one you are calling before writing the miss path.

REST equivalents

MethodPathReturns
GET/api/stores/:storeId/blog/postsBlogPostListResponse
GET/api/stores/:storeId/blog/posts/:slugBlogPost or 404
GET/api/vc/:connectionId/blog/postsSame shapes, channel-scoped
GET/api/vc/:connectionId/blog/posts/:slugBlogPost or 404
GET/api/blog/posts?storeId=... (admin)BlogPostListResponse
GET/api/blog/posts/:id?storeId=... (admin)BlogPost or 404
POST/api/blog/posts?storeId=... (admin)BlogPost
PATCH/api/blog/posts/:id?storeId=... (admin)BlogPost
POST/api/blog/posts/:id/publish?storeId=... (admin)BlogPost
POST/api/blog/posts/:id/unpublish?storeId=... (admin)BlogPost
POST/api/blog/posts/:id/duplicate?storeId=... (admin)BlogPost (new DRAFT)
DELETE/api/blog/posts/:id?storeId=... (admin)204