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, imported once from Shopify/WooCommerce with the Migration Tool, or synced from a connected platform app such as TikTok)

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.
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. Stripe Elements needs this exact value. It is NOT reliably a URL. 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. It carries no inventory object: read kitAvailable instead (null = unlimited, 0 = not sellable), or a sold-out kit renders as buyable. kitComponents comes back on the by-slug read and is display only. 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.
  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 and sessionToken are safe to store. customerToken is not: 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 vibe-coded 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.

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.
  2. Cache product prices or inventory. Always fetch fresh data. Prices and inventory change in real-time.
  3. Process payments yourself or collect credit card numbers. Always use the Brainerce Payment Intent API + the provider's client SDK (e.g., Stripe Elements).
  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.

Multi-language storefronts

If the merchant configured more than one language (Settings → Languages), 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).

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}
  • All responses return HTTP 200 on success, with JSON body
  • 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;
}

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

  // 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;
    }
    throw { statusCode: response.status, message: error.message, error: error.error };
  }

  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, guest order lookup, order 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)

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, i18n, SEO tokens) plus 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 }
    ],
    "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].provider"stripe"Use Stripe Elements for payment (Task 5)
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)
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)
connection.reservationStrategy"ON_PAYMENT"The default, so this covers most stores. Stock is only held at payment: neither the Cart nor the Checkout carries reservation, so render no countdown. 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,
  status: 'active',
  sortBy: 'createdAt',
  sortOrder: 'desc',
});

REST API

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

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": { "quantity": 50, "reserved": 3 },
      "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": { "quantity": 12, "reserved": 1 }
        },
        {
          "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": { "quantity": 0, "reserved": 0 }
        }
      ],
      "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
searchstringnonenoneSearch by name, SKU, or description
statusstringnonenone"active" or "draft". Always use "active" for storefront.
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.
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"
localestringnonenoneDeprecated. Use client.setLocale(locale) once instead. The SDK 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
Vibe-coded, /api/vc/{salesChannelId}/products (this guide)name, price, createdAtSilently ignored, you get the curated order back. No 400, no warning.
Storefront (storeId) and admin (/api/v1/products)name, price, createdAt, updatedAt, menuOrderRejected with 400

Two consequences worth spelling out. Passing the literal string menuOrder on the vibe-coded 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.

The SDK types ProductQueryParams.sortBy as the three-value vibe-coded set, so a TypeScript caller cannot pass updatedAt or menuOrder in any mode without casting. If you need those, call /api/v1/products with raw HTTP.

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 vibe-coded site 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 vibe-coded sites: 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) {
  const availableStock = product.inventory
    ? product.inventory.quantity - product.inventory.reserved
    : 0;

  const isOutOfStock = availableStock <= 0;
  const isLowStock =
    storeConfig.lowStockWarning &&
    availableStock > 0 &&
    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, storeConfig.currency),
    originalPrice: hasDiscount ? formatPrice(product.basePrice, storeConfig.currency) : null,
    isOutOfStock,
    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 קערה or %D7%A7%D7%A2%D7%A8%D7%94 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 stock > 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". 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. See Variants on the changelog for the full route list.

  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, call findVariant(product.variants, selected) to get the matching ProductVariant and update price/image/stock from it. To grey out unavailable combinations, run findVariant() for each candidate value and check inventory.canPurchase.

  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

Optional query param: ?type=RELATED or ?type=UPSELL or ?type=CROSS_SELL. Omit to get all types.

Returns array of product objects. Display as "You might also like" section below the product.

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
Content-Type: multipart/form-data
// SDK (recommended)
const { url } = await client.uploadCustomizationFile(file);

// 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', {
  method: 'POST',
  body: fd,
});
const { url } = await res.json();

Rules:

  • image/* only. Server rejects other MIME types with HTTP 400.
  • Max 5 MB per file.
  • 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 (vibe-coded 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",
        "modifierGroupId": "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. Every key above is a reference to a cart or checkout: the worst an attacker does with one is see a basket. The customer token is a 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.

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) {
      // Cart expired or not found — fall through to create new one
    }
  }

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

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.

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": [],
  "createdAt": "2026-04-09T12:00:00.000Z",
  "updatedAt": "2026-04-09T12:00:00.000Z"
}

Display itemCount on the cart icon/badge in the header.

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 is required for variable products"
  • 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",
  "discountAmount": "0.00",
  "promoDiscountAmount": "0.00",
  "promoSource": null,
  "promoSourceId": null,
  "notes": 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": "..." }
  }
}

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.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.

Step 3.4: Remove item

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

No body. Response: updated cart.

Step 3.5: Clear all items

DELETE /cart/{cartId}/items

No body. Response: empty cart.

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

function renderCart(cart) {
  if (cart.items.length === 0) {
    return showEmptyCart(); // "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, cart.currency),
      quantity: item.quantity,
      lineTotal: formatPrice(String(parseFloat(item.unitPrice) * item.quantity), cart.currency),
      promoLabel:
        item.promoSource === 'BUNDLE'
          ? 'Bundle deal'
          : item.promoSource === 'ORDER_BUMP'
            ? 'Added offer'
            : null,
    });
  }

  // Totals
  const subtotal = parseFloat(cart.subtotal);
  const couponDiscount = parseFloat(cart.discountAmount);
  const autoDiscount = parseFloat(cart.ruleDiscountAmount || '0');
  const promoDiscount = parseFloat(cart.promoDiscountTotal || '0');
  const totalDiscount = couponDiscount + autoDiscount + promoDiscount;
  const total = subtotal - totalDiscount;

  renderTotals({
    subtotal: formatPrice(cart.subtotal, cart.currency),
    couponDiscount: couponDiscount > 0 ? formatPrice(cart.discountAmount, cart.currency) : null,
    autoDiscount: autoDiscount > 0 ? formatPrice(cart.ruleDiscountAmount, cart.currency) : null,
    couponCode: cart.couponCode,
    total: formatPrice(String(total), 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.message);
    }
  }
}

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 with a ?brainerce_cart=<cartId> query param.

You don't need to handle that param yourself. As long as your cart page resolves the active cart through the SDK's session helpers (e.g. getOrCreateSessionCart() / getCart()), the SDK reads ?brainerce_cart on load and adopts that cart automatically, so the recovered items show up with no extra code. Just make sure your cart route renders the SDK's active cart on mount, and you're done.

If you resolve the cart by your own stored id instead, read the param yourself:

const recoverId = new URLSearchParams(location.search).get('brainerce_cart');
const cart = await client.getCart(recoverId ?? myStoredCartId);

Task 4: Checkout flow

Checkout is multi-step. Complete steps in order.

Step 4.1: Create checkout from cart

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

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 });
// estimateTax(params: { country?: string; subtotal: number }): Promise<TaxEstimateResponse>
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. 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. Canada is the case you will actually hit: rate and rateName summarise the set (14.975, "GST + QST") and rates[] itemises it. A preview has no province, so a Canadian estimate shows the federal GST alone at 5% and the provincial PST or QST joins it once the buyer gives a shipping address. 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).

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

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: [{ "country": "IL", "name": "Israel", "regions": [...] }, ...]

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 Google's own administrative-area code, usually (though not guaranteed) the same ISO 3166-2 subdivision code this platform's own region lists use. 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.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.

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, 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.


Task 5: Payment

This is the most critical task. The flow depends on the payment provider.

Payment flow overview

Step 5.1: Get available payment providers
Step 5.2: Create payment intent
Step 5.3: Process payment (provider-specific)
Step 5.4: Handle redirect / poll payment status
Step 5.5: Complete checkout and create order

Step 5.1: Get payment providers

GET /payment/providers

Response:

[
  {
    "id": "inst_stripe123",
    "name": "Stripe Payments",
    "provider": "stripe",
    "methodType": "CREDIT_CARD",
    "presentation": "card_form",
    "isAdditive": false,
    "isDefault": true
  },
  {
    "id": "inst_paypal456",
    "name": "PayPal",
    "provider": "paypal",
    "methodType": "WALLET",
    "presentation": "express_button",
    "isAdditive": true,
    "isDefault": false
  }
]

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 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.)

If empty → show "Payment is not configured" and block checkout.

clientSdk on each provider tells you how it renders. clientSdk.renderType is the provider's default mode ('redirect', 'iframe', 'sdk-widget', 'embedded-fields' or 'sandbox'), and clientSdk.displayModes lists every mode the provider can serve. A provider whose displayModes holds both 'redirect' and 'iframe' (iCredit, MAX) will present either, and Step 5.2 shows how to ask. displayModes is absent on an older manifest; treat that as [renderType].

Step 5.2: Create payment intent

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 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.

{
  "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.

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

Predict the mode before you create the intent, because your successUrl depends on it. 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,
});

Always branch on the clientSdk.renderType that comes BACK, never on 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:

{
  "id": "pi_abc123",
  "clientSecret": "pi_abc123_secret_xyz",
  "amount": "499.80",
  "currency": "ILS",
  "status": "pending",
  "provider": "stripe",
  "metadata": {},
  "clientSdk": {
    "initConfig": {
      "publishableKey": "pk_live_xxx"
    }
  },
  "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: 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. 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: Process payment based on provider

If provider is "stripe"

Install Stripe:

npm install @stripe/stripe-js
import { loadStripe } from '@stripe/stripe-js';

// 1. Initialize Stripe with the key from paymentIntent response
const stripe = await loadStripe(paymentIntent.clientSdk.initConfig.publishableKey);

// 2. Create Elements with the clientSecret
const elements = stripe.elements({
  clientSecret: paymentIntent.clientSecret,
  appearance: { theme: 'stripe' },
});

// 3. Mount the payment form into a <div id="payment-element"></div> on your page
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');

// 4. When user clicks "Pay now"
async function handlePayNow() {
  // IMPORTANT: Save checkoutId before redirect
  localStorage.setItem('brainerce_checkout_id', checkoutId);

  const { error } = await stripe.confirmPayment({
    elements,
    confirmParams: {
      return_url: `${window.location.origin}/order-confirmation`,
    },
  });

  if (error) {
    // Card declined, expired, etc. — show error, do NOT create new checkout
    showError(error.message);
  }
  // If no error: Stripe redirects the browser to return_url automatically
}

Important: The Stripe card form is rendered by Stripe's SDK. You do NOT build your own credit card input fields.

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. For Stripe it is the real client secret that Elements needs. 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 (Morning, PayPal, Grow, Sola), which is exactly why reading clientSecret looks right in testing and then fails in production. MAX and Takbull 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.

Cardcom and Stripe set no renderArg at all, 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.

If clientSdk.renderType is 'iframe' (CardCom embedded, CardCom hosted, Sola, legacy Grow)

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

Flavor A, the Brainerce-hosted embed (URL path contains /embed/): CardCom in renderMode: 'embedded' (default), or Sola iFields. The iframe loads a Brainerce-branded compact form with the provider's PCI-scoped card fields inside. Render INLINE in the checkout flow, with NO modal and NO dark overlay. Listen for these postMessages from the embed:

  • brainerce:resize → update the iframe height to fit its content
  • brainerce:redirect → the embed requested a top-level navigation (e.g. Bit express-pay). ALWAYS validate against an allowlist before navigating. The SDK ships isAllowedPaymentUrl() for this, and it covers Stripe, PayPal, CardCom, Meshulam, Grow, CreditGuard plus Brainerce-hosted embed hosts, and updates flow to your store automatically with npm update brainerce. Pass { extraHosts: [...] } to add a self-hosted PSP.
  • brainerce:payment-completeyou MUST call confirmSdkPayment(checkoutId, e.data.data) here, then navigate to your confirmation page. For CardCom the charge already happened inside the iframe, so this call is just a verify. For Sola (and any other confirm-time-charge provider) the card was only tokenized inside the iframe: e.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: polling payment-status alone can never recover a Sola payment (it is deliberately excluded from server-side reconciliation, since retrying its confirm without the original tokens would either double-charge or fail outright).

Flavor B, a provider-hosted page (any other URL): CardCom in renderMode: 'hosted', or any other iframe provider that renders its own full branded page. Render inside a modal overlay so it doesn't fight your checkout layout.

import { isAllowedPaymentUrl } from 'brainerce';

if (paymentIntent.clientSdk?.renderType === 'iframe') {
  // renderArg first, clientSecret only as the fallback. See the note below.
  const iframeUrl = paymentIntent.clientSdk.renderArg || paymentIntent.clientSecret;
  const isBrainerceEmbed = new URL(iframeUrl).pathname.includes('/embed/');

  window.addEventListener('message', (e) => {
    if (e.data?.type === 'brainerce:resize') setIframeHeight(e.data.height);
    if (e.data?.type === 'brainerce:redirect' && isAllowedPaymentUrl(e.data.url)) {
      window.top.location.href = e.data.url; // e.g. Bit handoff
    }
    if (e.data?.type === 'brainerce:payment-complete') {
      // REQUIRED: forward e.data.data — for confirm-time-charge providers
      // (Sola) this carries the card tokens the server needs to run the
      // charge. Do not skip this and just navigate.
      client
        .confirmSdkPayment(checkoutId, e.data.data)
        .catch((err) => console.warn('confirmSdkPayment failed:', err))
        .finally(() => {
          window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
        });
    }
  });

  if (isBrainerceEmbed) {
    // Render inline — part of the checkout flow
    renderIframeInline(iframeUrl);
  } else {
    // Render inside a modal overlay
    renderIframeInModal(iframeUrl);
  }
}

If clientSdk.renderType is 'redirect' (hosted-page providers: PayPal, Morning, Takbull, iCredit)

clientSdk.renderArg is the URL, with clientSecret as the fallback (see the note above). Navigate the top-level window to it:

import { safePaymentRedirect } from 'brainerce';

if (paymentIntent.clientSdk?.renderType === 'redirect') {
  localStorage.setItem('brainerce_checkout_id', checkoutId);
  const paymentUrl = paymentIntent.clientSdk.renderArg || paymentIntent.clientSecret;
  safePaymentRedirect(paymentUrl); // validates the host, then navigates
  // User completes payment on the external page, then returns to your site
}

On return (your success page), trigger server-side verify-and-capture FIRST, then poll payment-status exactly like Stripe (step 5.4):

// Redirect providers (e.g. PayPal) don't capture the payment until the server
// confirms it. This call makes the backend verify the payment with the
// provider and capture it. It is idempotent — safe if the payment was already
// captured by a webhook — and safe to skip on failure (polling also verifies).
try {
  await brainerceAPI('POST', '/payment/sdk-confirm', { checkoutId });
} catch {
  // Not fatal: payment-status polling (step 5.4) re-verifies server-side.
}

If the buyer canceled on the provider page, they return to your cancelUrl. Do NOT call sdk-confirm there; just let them retry.

If provider is "sandbox"

No payment UI needed. Skip directly to step 5.5.

if (paymentIntent.provider === 'sandbox') {
  const order = await brainerceAPI('POST', `/checkout/${checkoutId}/complete`);
  handleOrderSuccess(order);
}

Step 5.4: Handle payment redirect and verify status

After Stripe processes payment, the browser is redirected to your return_url with query parameters:

https://your-site.com/order-confirmation?payment_intent=pi_abc&redirect_status=succeeded

On your order confirmation page, verify and complete:

async function handleOrderConfirmation() {
  const params = new URLSearchParams(window.location.search);
  const redirectStatus = params.get('redirect_status');
  const checkoutId = localStorage.getItem('brainerce_checkout_id');

  if (!checkoutId) {
    showError('Session expired. Please contact support.');
    return;
  }

  if (redirectStatus === 'failed') {
    showError('Payment failed. Please try again.');
    return;
  }

  // Poll payment status (handles both instant and delayed confirmations)
  let attempts = 0;
  while (attempts < 10) {
    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 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));
    attempts++;
  }

  // Timeout after 20 seconds — show 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');
}

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."
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.

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 same order shape as the guest lookup below (including notes, the shopper's own order note). Render whatever subset fits your design.

Guest order lookup page

Build a simple form where customers can check their order status:

POST /orders/lookup
{
  "email": "[email protected]",
  "orderNumber": "ORD-00042"
}

Found: returns full order object. Not found: HTTP 404, so show "Order not found."

Order response

{
  "id": "order_abc123",
  "orderNumber": "ORD-00042",
  "status": "processing",
  "financialStatus": "paid",
  "fulfillmentStatus": "unfulfilled",
  "totalAmount": 529.7,
  "subtotal": 499.8,
  "shippingAmount": 29.9,
  "taxAmount": 0,
  "discountAmount": 0,
  "currency": "ILS",
  "items": [
    {
      "name": "Blue Running Shoes - Size 42 / Blue",
      "sku": "BRS-001-42-BL",
      "quantity": 2,
      "price": 249.9,
      "totalPrice": 499.8,
      "image": "https://..."
    }
  ],
  "customer": {
    "email": "[email protected]",
    "firstName": "John",
    "lastName": "Doe",
    "phone": "0501234567"
  },
  "shippingAddress": {
    "firstName": "John",
    "lastName": "Doe",
    "line1": "Rothschild 123",
    "city": "Tel Aviv",
    "postalCode": "6100000",
    "country": "IL"
  },
  "trackingNumber": null,
  "trackingUrl": null,
  "carrier": null,
  "createdAt": "2026-04-09T12:30:00.000Z"
}

How to display order status

statusfinancialStatusfulfillmentStatusShow to customer
"pending""pending""unfulfilled""Order received, awaiting payment"
"processing""paid""unfulfilled""Order confirmed, being prepared"
"processing""paid""partially_fulfilled""Partially shipped"
"shipped""paid""fulfilled""Shipped" + tracking link
"delivered""paid""fulfilled""Delivered"
"cancelled""refunded"any"Cancelled and refunded"
"cancelled""pending"any"Cancelled"

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


Task 7: Error handling

API error format

{
  "statusCode": 400,
  "message": "Variant is required for variable products",
  "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
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;

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 res = await fetch(`${BASE}${path}`, opts);
  if (!res.ok) throw await res.json();
  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?status=active&limit=12');

// 3. CART
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
// Optional: ask for a presentation mode with `preferredRenderType: 'iframe' |
// 'redirect'`. Honoured only when the provider's clientSdk.displayModes lists
// it; otherwise you get the provider default and `renderModeResolution:
// 'fallback'`. Predict it with resolveRenderType() first so `successUrl`
// matches (Task 5, Step 5.2).
const payment = await api('POST', '/payment/intent', { checkoutId: checkout.id });

// Branch on the clientSdk.renderType that came BACK — NEVER hard-code by
// provider name, and never assume the mode you asked for.
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 === 'sdk-widget') {
  // Load payment.clientSdk.scriptUrl, then mount the provider's widget into
  // <div id={payment.clientSdk.containerId}>. Used by Stripe, PayPal, Grow.
  // See Task 5, Step 5.3 for full Stripe code.
} else if (renderType === 'iframe') {
  // CardCom (embedded or hosted), legacy iframe providers.
  // Path contains '/embed/' → render INLINE with postMessage listeners.
  // Otherwise → render inside a modal overlay. See Step 5.3 for the full pattern.
} else if (renderType === 'redirect') {
  // renderArg is the URL; clientSecret is only the fallback (see Step 5.3).
  window.location.href = payment.clientSdk.renderArg || payment.clientSecret;
  // After payment, user returns to your site — call POST /payment/sdk-confirm
  // once (triggers server-side capture), then poll payment-status (Task 5, step 5.4)
}

// 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:

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

Checkout

  • Checkout creates from cart
  • 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
  • Payment intent creates
  • Stripe Elements mount (if Stripe)
  • Sandbox completes directly
  • CheckoutId saved before redirect
  • Redirect providers: sdk-confirm called once on return (before polling)
  • Confirmation page polls status
  • Order created after payment verified
  • Order number displayed

Post-Purchase

  • localStorage cleared
  • Confirmation email mentioned
  • Guest order lookup works
  • 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 (public, no auth needed)

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:

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.

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.

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 / vibe-coded 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.

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);
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
DELETE/api/blog/posts/:id?storeId=... (admin)204

On this page

Pre-requirementsHow it worksField glossarySecurity rulesDo NOTMulti-language storefrontsAPI base URLTwo integration methodsMethod A: npm SDK (recommended for React, Next.js, Vue, Svelte)Method B: Direct REST API (works with any language or framework)Task listREQUIRED TASKSTask 1: Discover store capabilitiesUse the SDK methodREST APIResponseDecision table: what to build based on capabilitiesSave these values in your app stateTask 2: Products, categories & searchStep 2.1: Fetch categories for navigationStep 2.1a: Category (collection) pageStep 2.2: Fetch brandsStep 2.3: Fetch tags (for filtering)Step 2.4: Product listing pageSDKREST APIResponseQuery parameterssortBy: the default is the curated shelf order, and the accepted set differs by modeStep 2.4a: Filter by custom fields (metafields)Facet value counts (GET /metafield-filters)How to render a product cardPaginationStep 2.5: Search suggestions (autocomplete)Step 2.6: Single product pageHow to build the product pageRendering attribute selectors with swatch support (getProductSwatches)Step 2.7: Product recommendations (optional)Step 2.8: Product customization fields (buyer input)Step 2.8a: Upload buyer-submitted imagesStep 2.8b: Display customization labels in the cart and checkoutStep 2.8c: Validate before add-to-cartStep 2.9: Modifier groups (restaurant / build-your-own products)Step 2.9a: Pass selections on add-to-cartStep 2.9b: Editing selections after add-to-cart (idempotent)Step 2.9c: When validation failsTask 3: Shopping cartlocalStorage keys used by this integrationStep 3.1: Create or load cartREST APICart responseStep 3.2: Add item to cartCart item structureStep 3.2b: Add-to-cart with customization valuesStep 3.3: Update item quantityStep 3.4: Remove itemStep 3.5: Clear all itemsStep 3.6: Apply couponStep 3.6b: Apply coupon during checkout (preferred)How to render the cart pageAbandoned-checkout recovery (automatic)Task 4: Checkout flowStep 4.1: Create checkout from cartCheckout responseStep 4.2: Set customer informationStep 4.3: Choose the right checkout flowStep 4.4: Set delivery typeStep 4.5: Set shipping address (only if deliveryType = "shipping")Optional: address autocomplete on the line1 fieldStep 4.6: Select shipping method (only if availableShippingRates not empty)Step 4.7: Select pickup location (only if deliveryType = "pickup")Step 4.7b: Checkout custom fields (only if the merchant defined any)Step 4.8: Set billing address (optional)Step 4.9: Order summarytaxBreakdown.subtotal is not the subtotal rowA gift card is a means of payment, not a discountMore than one tax on the same orderTask 5: PaymentPayment flow overviewStep 5.1: Get payment providersStep 5.2: Create payment intentStep 5.3: Process payment based on providerIf provider is "stripe"renderArg is the URL, clientSecret is the identifierIf clientSdk.renderType is 'iframe' (CardCom embedded, CardCom hosted, Sola, legacy Grow)If clientSdk.renderType is 'redirect' (hosted-page providers: PayPal, Morning, Takbull, iCredit)If provider is "sandbox"Step 5.4: Handle payment redirect and verify statusPayment status endpointStep 5.5: Complete checkoutCommon payment errorsTask 6: Order confirmation & lookupShow order confirmationGuest order lookup pageOrder responseHow to display order statusTask 7: Error handlingAPI error formatError display rulesWhat's nextComplete exampleChecklistProducts & NavigationCartCheckoutPaymentPost-PurchaseErrorsContact Inquiries (optional)SDK, simpleSDK, flexible formsFull dynamic rendering example (React)RESTRulesProduct ReviewsRead reviews on a PDP (public, no auth needed)Render the right form state for the current customerSubmit, edit, deleteAttach photosJSON-LD (SEO)SDK JSON-LD builders, preferred over hand-rolled objectsSEO & Discoverability (REQUIRED)Content: typed merchant content storeDefault keyReading content (public, any SDK mode)Security: sanitize HTML before renderingProduct descriptions can contain video and embedsSite chrome: fetch in root layoutCustom fieldsTranslationsRTL directionAdmin writes (apiKey mode only)REST equivalentsBlogSDK exampleAdmin writes (apiKey mode only)REST equivalents