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, andget-critical-rulestools, 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 withvc_) obtained from the Brainerce dashboard sidebar under SELL → Sales Channels. (The SDK still accepts the old nameconnectionIdas 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:
- Display products: fetch product data (name, price, images, variants) from Brainerce
- Navigation: fetch categories, brands, tags, and search results
- Shopping cart: create and manage a cart server-side via API
- Checkout: collect customer info, shipping address, and select delivery method
- Payment: create a payment intent and process payment via Brainerce's configured payment provider
- Orders: orders are created automatically after successful payment
- Customer accounts: optional registration, login, order history
Your website handles only the UI. All data and business logic lives in Brainerce.
Field glossary
| Brainerce term | Meaning |
|---|---|
salesChannelId | Your 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. |
storeId | The Brainerce store this sales channel belongs to. You do NOT need this, because the salesChannelId resolves it automatically. |
sessionToken | A unique token for a guest shopping cart. Store it in localStorage to persist the cart across page loads. |
cartId | The server-side cart identifier. Always returned with the cart object. |
checkoutId | Created when the user starts checkout. Used to set addresses, select shipping, and process payment. |
variantId | A specific product option combination (e.g., "Large / Red"). Required when adding variable products to cart. |
slug | URL-friendly product name (e.g., "blue-running-shoes"). Use for product page URLs. |
clientSecret | The payment identifier returned by the payment intent API. Stripe Elements needs this exact value. It is NOT reliably a URL. See renderArg vs clientSecret. |
renderArg | clientSdk.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 fields | All 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 product | A product with no variants. Use productId only when adding to cart. |
VARIABLE product | A product with variants (e.g., sizes, colors). You MUST provide both productId and variantId when adding to cart. |
KIT product | A 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
- HTTPS only. All API calls MUST use
https://. Never usehttp://in production. ThesalesChannelIdis transmitted in the URL and must be encrypted in transit. - Never expose customer tokens in URLs. The
Authorization: Bearer {token}header must only be sent via headers, never as a URL query parameter. - 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
textContentinstead ofinnerHTML). NoteProduct.descriptionis 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. - Never store the customer auth token in localStorage.
salesChannelIdandsessionTokenare safe to store.customerTokenis 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. - 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 anOriginheader — server-side calls do not bypass that check, they are rejected by it with403 Origin header required, today and not "in the future". Server-side rendering, Route Handlers and background jobs are still supported: passoriginwhen 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'sdomain. Do not reach for an adminbrainerce_*key instead — that swaps a scoped per-channel credential for a full-access store-wide one. See Origin rules. - Switch the channel to LIVE mode in production. Every Sales Channel has a
mode(TEST or LIVE) and a singledomain. In TEST mode the Origin is not matched against a domain, solocalhostand 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
- Build your own cart or pricing logic client-side. Always use the Brainerce Cart API. The server handles pricing, discounts, tax, and inventory.
- Cache product prices or inventory. Always fetch fresh data. Prices and inventory change in real-time.
- Process payments yourself or collect credit card numbers. Always use the Brainerce Payment Intent API + the provider's client SDK (e.g., Stripe Elements).
- Call
completeCheckoutbefore verifying payment status. The order should only be completed after payment is confirmed. - Store customer passwords. Brainerce handles authentication. You only store the
tokenreturned after login/register. - Hardcode currency symbols (e.g.,
"$"or"₪"). Use thecurrencyfield from the store response and format withIntl.NumberFormat. - 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).
- Assume product availability. Always check the
inventoryfield before showing "Add to Cart". - Skip checkout steps. You must set customer info AND either shipping address or pickup location before payment.
- Create a new checkout when payment fails. The existing checkout is still valid. Let the user retry.
- Use
innerHTMLto render API data. Always usetextContentor your framework's safe rendering to prevent XSS. - Log customer tokens, emails, or addresses to the browser console in production. Remove all
console.logstatements 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 otherwiseOr 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()beforegetProducts({ search }). - Checkout freezes the locale. Call
setLocale()beforecreateCheckout()so the order capturesorder.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
salesChannelIdin 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 }.codeis a stable machine-readable identifier; see Rules: Error codes for which endpoints set a specific one and which fall back to a genericBAD_REQUEST/NOT_FOUND.
Two integration methods
You can use either method. Both produce the same result. Both are shown for every task.
Method A: npm SDK (recommended for React, Next.js, Vue, Svelte)
npm install brainerceimport { BrainerceClient } from 'brainerce';
const client = new BrainerceClient({
salesChannelId: 'vc_YOUR_SALES_CHANNEL_ID',
});Use
salesChannelId, notconnectionId.connectionIdis 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; usesalesChannelIdfor new code regardless. The value itself is unchanged: the samevc_*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):
| # | Task | What it does |
|---|---|---|
| 1 | Discover store capabilities | Learn what features are available (payment, shipping, OAuth, etc.) |
| 2 | Products, categories & search | Display products, navigation, filtering, search |
| 3 | Shopping cart | Cart CRUD, coupons, nudges |
| 4 | Checkout flow | Customer info, addresses, shipping/pickup, order summary |
| 5 | Payment | Payment intent, Stripe integration, status polling |
| 6 | Order confirmation | Success page, guest order lookup, order status display |
| 7 | Error handling | Global error handler, user-friendly messages |
Optional enhancements (implement any or all, in any order):
| # | Task | What it does |
|---|---|---|
| 8 | Customer accounts | Registration, login, password reset, email verification |
| 9 | Customer profile & order history | Profile management, saved addresses, past orders |
| 10 | Social login (OAuth) | Google, Facebook, GitHub sign-in |
| 11 | Discount banners & promotions | Promotional banners, product badges, free shipping bar |
| 12 | Upsells: bundles, bumps & upgrades | Cross-sell in cart and checkout |
| 13 | Inventory countdown & reservation | Real-time stock countdown, reservation timer |
| 14 | Digital product downloads | Download files after purchase |
| 15 | Loyalty & rewards | Points balance + pending points, tiers, redemption, badges |
| 16 | Referrals & birthday gifts | Member share links, referral landing page, birthdays |
| 17 | Paid loyalty membership | Premium subscription plans on a saved card |
| 18 | Embeddable loyalty widget | Drop-in iframe for pages outside the storefront |
| 19 | Gift cards | Redeem 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 /capabilitiesSales-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
| Capability | Value | What to do |
|---|---|---|
features.paymentProviders | empty 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.sandboxPaymentsEnabled | true | Show a "Test Mode" banner on the site |
features.hasCoupons | true | Show coupon input on cart page |
features.hasCoupons | false | Hide coupon input |
features.hasGiftCards | true | Show 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.hasGiftCards | false | Hide 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.hasShippingZones | true | Show shipping address form + shipping method selection in checkout |
features.hasShippingZones | false | Skip shipping address and method, only collect customer info |
features.hasDiscountRules | true | Implement discount banners (Task 11) |
features.hasDownloadableProducts | true | Implement download access after purchase (Task 14) |
features.oauthProviders | has items with isEnabled: true | Show social login buttons (Task 10) |
connection.requireEmailVerification | true | Show email verification step after registration |
connection.requireBirthday | true | Make 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.lowStockWarning | true | Show "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.lowStockWarning | false | Show 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 /categoriesResponse, 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
metaDescriptioninto<meta name="description">and thedescriptionHTML below the product grid (so products stay above the fold). Always sanitize the HTML before rendering. - Emit
buildCollectionPageJsonLd+buildBreadcrumbJsonLd, neverbuildProductJsonLdon a listing page. - Include category pages in
sitemap.xmlviagetCategorySitemapEntries.
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=descResponse
{
"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
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
page | number | 1 | none | Page number (starts at 1) |
limit | number | 50 | 100 | Items per page |
search | string | none | none | Search by name, SKU, or description |
status | string | none | none | "active" or "draft". Always use "active" for storefront. |
categories | string | none | none | Comma-separated category IDs: "cat_1,cat_2" |
brands | string | none | none | Comma-separated brand IDs |
tags | string | none | none | Comma-separated tag IDs (not names). GET /tags returns the ids: "tag_1,tag_2" |
minPrice | number | none | none | Minimum price filter |
maxPrice | number | none | none | Maximum price filter |
metafields | object | none | none | Custom-field filters (see Step 2.4a). SDK only, because REST sends a JSON-encoded string. |
sortBy | string | curated | none | "name", "price" or "createdAt" on this surface. Omit it for the merchant's curated order. Read the note below before you use it. |
sortOrder | string | asc | none | "asc" or "desc" |
locale | string | none | none | Deprecated. 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:
| Surface | Accepted sortBy | Unknown value |
|---|---|---|
Vibe-coded, /api/vc/{salesChannelId}/products (this guide) | name, price, createdAt | Silently ignored, you get the curated order back. No 400, no warning. |
Storefront (storeId) and admin (/api/v1/products) | name, price, createdAt, updatedAt, menuOrder | Rejected 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 BOOLEANPer-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%7DFacet 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(typesSELECT/MULTI_SELECT/BOOLEANonly), subject to the same per-site publishing gate as the products list. countis 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_SELECTarrays are split, so a product holding["a","b"]counts once underaand once underb.BOOLEANbuckets are always"true"/"false".- Declared
enumValuesalways appear (zero-count entries included, so you can render disabled options); stray stored values outsideenumValuesare reported as-is. - Pair each
keywithgetProducts({ 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
isOutOfStockis true and the item cannot be backordered, offerstockAlerts.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 thevariantIdthe 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=5Response:
{
"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, thenencodeURIComponents), so passing eitherקערהor%D7%A7%D7%A2%D7%A8%D7%94produces the same wire form. If you build the URL by hand (rawfetch), apply the same normalization:encodeURIComponent(decodeURIComponent(slug)). A double-encoded non-ASCII slug 404s.Region display pricing: all three reads accept
?regionId=, namelyGET /products?regionId=,GET /products/{id}?regionId=, andGET /products/slug/{slug}?regionId=. Each returns the additivedisplayPrice/displaySalePrice/displayCurrencyfields. 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:
- Show
name,description, allimages, price - Show "Add to Cart" if stock > 0
- The "Add to Cart" call uses only
productId(novariantId)
For VARIABLE products:
Price range: Use
product.priceMin/product.priceMaxfor catalog cards and JSON-LD structured data.product.priceVariesistruewhen the range should be shown as "₪49 to ₪199". For the currently-selected variant on a product detail page, usegetVariantPrice(selectedVariant, product.basePrice).
basePrice/salePriceforVARIABLEproducts are aggregated from variants. The storefront API returnsbasePrice=MIN(variants.price)andsalePrice=MIN(variants.salePrice WHERE NOT NULL)forVARIABLEproducts, i.e. the lowest price across variants, and the lowest sale price if at least one variant is on sale. This matches the WooCommerceis_on_sale()semantic and meansproduct.salePrice !== nullis a reliable "this product is on sale" signal for bothSIMPLEandVARIABLEproducts. Use the SDK helpergetProductPriceInfo(product), which implements this contract for you.
Writing variant prices. Because
basePriceis read-only forVARIABLEproducts,PATCH /v1/products/{id}cannot change their price. Set the price on each variant instead, withclient.updateVariant(productId, variantId, { price }), orclient.bulkSaveVariants(productId, { variants: [...] })to update many in one transaction (both require theproducts:writescope). Variants also accept acostPrice(a unit COGS override for margin analytics, which falls back to the product'scostPrice); it is merchant-internal and never appears on public storefront responses. See Variants on the changelog for the full route list.
- Show
name,description, allimages, price - 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:
displayType | Render as |
|---|---|
DEFAULT | Plain text button with the option name. Use this branch as the fallback for any unknown value too. |
COLOR_SWATCH | Round button filled with swatchColor. If swatchColor2 is set, use a 50/50 linear-gradient (e.g. Black & White). |
IMAGE_SWATCH | Square button with swatchImageUrl as <img> thumbnail. |
MIXED_SWATCH | Per-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.
- Show a selector (dropdown, buttons, or swatches) for each attribute key (e.g., "Size", "Color")
- When user selects all attributes → find the matching variant → update the displayed price, image, and stock status
- If variant not found or stock = 0 → disable "Add to Cart", show "Out of Stock" or "Unavailable"
- If variant has its own
image→ show it. Ifimageis null → use the product's main image. - The "Add to Cart" call uses both
productIdAND the selected variant'svariantId
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 onlyStep 2.7: Product recommendations (optional)
GET /products/{productId}/recommendationsOptional 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: trueon itsMetafieldDefinition. 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'scustomizationFieldsarray automatically, so your code does not need to merge or union anything. Render whatever is inproduct.customizationFieldsas-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:
| type | Render as | Collected value shape |
|---|---|---|
TEXT | <input type="text"> | string |
TEXTAREA | <textarea> | string |
NUMBER | <input type="number"> | number |
BOOLEAN | checkbox / switch | boolean |
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 enumValues | string (must be in enumValues) |
MULTI_SELECT | checkbox group, options from enumValues | string[] (each in enumValues) |
IMAGE | file input + upload to /customization-upload (see below) | string (asset URL) |
GALLERY | multi-file input + one upload call per file | string[] (asset URLs) |
JSON | textarea, parse before submit and validate as JSON | string (serialized JSON) |
DATE/DATETIMEavailability constraints. A field may carry adateAvailabilityobject (blocked weekdays, blocked specific dates, min/max date, relative bounds, and, forDATETIMEonly, business hours + time slots). Pass it, together with(await client.getStoreInfo()).timezone, intocomputeAvailableSlots()/getBusinessHoursForDate()/isDateValueAllowed()(all exported frombrainerce) 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,cutoffTimeandmaxDaysAheadare 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 (andnowas the 5th argument ofisDateValueAllowed()), or they are skipped and your picker offers days the server will refuse. A weekday may also carry more than onebusinessHourswindow, so a split day of mornings and evenings is two entries sharing aweekday.Two traps worth knowing before you build the picker: once
businessHourshas any entry, every weekday it doesn't list is closed all day (it's an allowlist), andcomputeAvailableSlots()returns[]wheneverslotDurationMinutesis unset even on an open day, so callgetBusinessHoursForDate()to tell "closed" from "not slot-based". SubmitDATETIMEas one ISO-8601 value (2026-08-13T13:00:00+03:00, or2026-08-13T13:00to mean store-local); never glue a slot label onto a date, because"2026-08-13T13:00-14:00"is rejected because-14:00reads 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:
requiredfields must be present and non-emptyTEXT/TEXTAREA: respectminLength/maxLength(characters)NUMBER: respectminValue/maxValueSELECT: value must be one ofenumValuesMULTI_SELECT: value is an array; each entry must be inenumValues; duplicates removed automatically;minLength/maxLengthtreated as array count boundsIMAGE/GALLERY: values must be URLs returned from/customization-uploadon 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):
| Field | Meaning |
|---|---|
selectionType | SINGLE (radio, max 1) or MULTIPLE (checkbox, bounded by min/max) |
min / max | Effective bounds. max: null = unlimited. max: 0 means the group is hidden for this variant |
freeQuantity | First N picks are at no extra cost |
required | Customer must pick at least 1 (typically min: 1 too) |
freeAllocationPolicy | EXPENSIVE_FREE (best for customer) / CHEAPEST_FREE (best for merchant) / SELECTION_ORDER |
defaultModifierIds | Pre-checked options on first render |
modifiers[].priceDelta | Decimal string. Negative values are valid downsell modifiers (e.g., "-2.00" for "no bread") |
modifiers[].available | false = sold out. Render disabled with a "sold out" badge |
modifiers[].isDefault | Pre-check this modifier on first render (alongside any defaultModifierIds on the group) |
modifiers[].referencedProductId | Nested combo. See Optional Features "Restaurant features" |
modifiers[].excludeFromFree | true = 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
| Key | What it stores | When to set | When to clear |
|---|---|---|---|
brainerce_session | Guest cart sessionToken | After creating a cart (step 3.1) | After successful order (Task 6) |
brainerce_cart_id | Cart id | After creating a cart (step 3.1) | After successful order (Task 6) |
brainerce_checkout_id | Checkout id | After 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}/itemsFor a SIMPLE product:
{
"productId": "prod_abc123",
"quantity": 1
}For a VARIABLE product (MUST include variantId):
{
"productId": "prod_abc123",
"variantId": "var_001",
"quantity": 1
}Rules:
VARIABLEproducts withoutvariantId→ API returns HTTP 400: "Variant is required for variable products"SIMPLEproducts: omitvariantIdentirely (do not sendnull)KITproducts: omitvariantIdtoo, and do not sendselections— 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 withINSUFFICIENT_STOCKnaming 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
metadatamatch thekeyfield of eachMetafieldDefinition(notname, notdefinitionId). - Omit optional empty fields. Don't send
""ornullunless the buyer actually cleared a default. MULTI_SELECTvalue is always an array, even with a single selection.IMAGE/GALLERYvalues must be URLs returned from/customization-uploadon 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
}quantityis the new total (not a delta). To go from 1→3, send3.- 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}/itemsNo 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}/couponStep 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}/couponResponse: updated Checkout object.
Why this matters: applying a coupon via
POST /cart/{cartId}/couponafter 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 acheckoutIdis 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 overlaySDK 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/destinationsReturns: [{ "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-locationsReturns 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 callRequired 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:
type | Control | Value 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 |
BOOLEAN | checkbox or switch | boolean |
SELECT | <select> built from definition.options | the option's value string |
DATE | date picker | YYYY-MM-DD string |
DATETIME | date and time picker | ISO timestamp string |
IMAGE | file upload | the 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:
| Field | Display |
|---|---|
lineItems | Product names, quantities, unit prices |
subtotal | Subtotal |
discountAmount | Coupon discount (show only if > 0) |
ruleDiscountAmount | Automatic discount (show only if > 0) |
shippingAmount | Shipping cost (show only if > 0) |
taxAmount | Tax (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 |
surchargeAmount | Surcharges (show only if > 0) |
total | What the order is worth, and the amount tax was calculated on |
tenders | Gift cards applied to this checkout, [{ tenderId, amountApplied }]. One line below the total, each |
providerAmountDue | What 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 orderStep 5.1: Get payment providers
GET /payment/providersResponse:
[
{
"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-jsimport { 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:
| Field | What it holds |
|---|---|
clientSecret | The provider's payment identifier. For Stripe it is the real client secret that Elements needs. Nothing guarantees it is a URL. |
clientSdk.renderArg | The 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 contentbrainerce:redirect→ the embed requested a top-level navigation (e.g. Bit express-pay). ALWAYS validate against an allowlist before navigating. The SDK shipsisAllowedPaymentUrl()for this, and it covers Stripe, PayPal, CardCom, Meshulam, Grow, CreditGuard plus Brainerce-hosted embed hosts, and updates flow to your store automatically withnpm update brainerce. Pass{ extraHosts: [...] }to add a self-hosted PSP.brainerce:payment-complete→ you MUST callconfirmSdkPayment(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.datacarries the token payload, and the real charge does not run until this call reaches the backend. Skip it and the checkout is stuck inPAYMENT_PENDINGforever: pollingpayment-statusalone 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=succeededOn 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-statusResponse:
{
"status": "succeeded",
"orderId": "order_abc123",
"orderNumber": "ORD-00042",
"paymentIntentId": "pi_abc123"
}status | Meaning | What to do |
|---|---|---|
"succeeded" | Payment confirmed | If orderId exists → show success. If null → call completeCheckout. |
"pending" | Still processing | Wait 2 seconds, poll again (max 10 times) |
"failed" | Payment failed | Show error, let user go back and retry |
"canceled" | User canceled | Show message, let user retry |
Step 5.5: Complete checkout
POST /checkout/{checkoutId}/completeNo body needed. Response:
{
"orderId": "order_abc123",
"orderNumber": "ORD-00042",
"status": "pending",
"total": 499.8,
"message": "Order created successfully"
}Common payment errors
| HTTP | Meaning | Show to customer |
|---|---|---|
| 400 | Missing address/customer | "Please complete all required fields before paying." |
| 400 | Out of stock | "Some items are no longer available. Please review your cart." |
| 402 | Payment declined | "Your payment was declined. Please try a different method." |
| 410 | Checkout expired | "Your checkout session has expired." Create a new checkout. |
| 500 | Server error | "Something went wrong. Please try again in a moment." |
| 503 | code: "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}/orderReturns 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
status | financialStatus | fulfillmentStatus | Show 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
| Context | HTTP | Show to customer | Log |
|---|---|---|---|
| Product not found | 404 | "This product is no longer available." | No |
| Add to cart: out of stock | 400 | "This item is out of stock." | Yes |
| Add to cart: variant missing | 400 | "Please select all options before adding to cart." | Yes |
| Coupon invalid | 400 | Show API message directly (it's user-friendly) | No |
| Checkout: missing fields | 400 | "Please complete all required fields." | Yes |
| Checkout: out of stock | 400 | "Some items are no longer available." | Yes |
| Checkout expired | 410 | "Your session has expired. Please try again." | No |
| Payment init fails | 400/500 | "Payment could not be started. Please try again." | Yes |
| Payment declined | 402 | "Payment declined. Try a different method." | Yes |
| Login fails | 401 | "Invalid email or password." | No |
| Registration: email exists | 400 | Show API message directly | No |
| Network error | none | "Connection error. Check your internet." | Yes |
| Server error | 500 | "Something went wrong. Try again shortly." | Yes |
| Token expired | 401 | Redirect to login | Clear token |
Rules:
- Never show raw API errors for 500 or payment errors
- Always show API
messagefor: 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 —
totaluntouched, discount block untouched - Applied cards read from
checkout.tendersso 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:
- Simple (legacy): call
createInquirywith{ name, email, subject, message }and you're done. This has worked since SDK 1.0 and will keep working forever. - 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
newsletteris 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, usemarketing.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-emptyhoneypotfield is rejected as a bot. - Unknown keys inside
fieldsare 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
idto 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; // 23Use 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 merchantSubmit, 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
imagesarray carries only what shoppers can see, and a pending photo is not that yet. CallgetMyProductReview()afterwards and rendermyImages, 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 Forbiddenwithreason: '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; useupdateMyProductReviewinstead.400 Bad RequestonimageKeys: 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) → additionallyPAID/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:
| Type | Use for | data shape |
|---|---|---|
FAQ | Q/A accordions | { items: { question, answer }[] } |
FOOTER | Site footer (chrome) | { columns, copyright?, social? } |
HEADER | Top nav + logo + CTA (chrome) | { logo?, navItems, cta? } |
ANNOUNCEMENT | Banners; time-bound by startsAt/endsAt | { message, severity, dismissible, startsAt?, endsAt?, ctaLabel?, ctaHref? } |
RICH_TEXT | Inline HTML blocks | { html } |
PAGE | Static 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/sourcetags plus playback attributes (controls,poster,preload,muted,loop,playsinline,type). - Allow
iframeonly for an embed host allowlist (www.youtube.com,www.youtube-nocookie.com,player.vimeo.com), never arbitrary iframes. The scaffold'ssanitizeProductHtml()atsrc/core/lib/sanitize-html.ts(imported as@/core/lib/sanitize-html) already does this — again, there is nosrc/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
| Method | Path | Returns |
|---|---|---|
| GET | /api/stores/:storeId/content?type=FAQ | Content[] |
| GET | /api/stores/:storeId/content/:type/:key | Content or 404 |
| GET | /api/stores/:storeId/content/pages/by-slug/:slug | Content<'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:
| Field | Type | Notes |
|---|---|---|
id | string | |
title | string | |
slug | string | Unique per store, URL-safe |
category | string? | Free-form label (e.g. 'news', 'guides') |
content | string | HTML from the rich-text editor |
excerpt | string? | Short summary for listings |
coverImageUrl | string? | |
author | string? | |
tags | string[] | |
publishedAt | string? | ISO 8601 |
seoTitle | string? | |
seoDescription | string? | |
ogImageUrl | string? | |
translations | Record<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
| Method | Path | Returns |
|---|---|---|
| GET | /api/stores/:storeId/blog/posts | BlogPostListResponse |
| GET | /api/stores/:storeId/blog/posts/:slug | BlogPost or 404 |
| GET | /api/vc/:connectionId/blog/posts | Same shapes, channel-scoped |
| GET | /api/vc/:connectionId/blog/posts/:slug | BlogPost 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 |