Storefront SDK Reference (brainerce)

Every reachable method on BrainerceClient, the storefront SDK. Grouped by domain, with the auth mode each one needs, ordered by the mandatory storefront flow.

Complete method reference for brainerce, the SDK you use to build a store: catalog, cart, checkout, payments, orders, customers, content.

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

const client = new BrainerceClient({ salesChannelId: 'vc_YOUR_SALES_CHANNEL_ID' });
const { data, meta } = await client.getProducts({ page: 1, limit: 20 });

This is not the App SDK. If you are building a marketplace app (a connector, a payment provider, a shipping carrier), you want the separate @brainerce/app-sdk package. See App SDK Reference.

This page is a reference: what exists, what it takes, and which credential it needs. For a guided build, read the Core Integration Guide first, then come back here.


The three auth modes

BrainerceClient takes exactly one credential. Which one you pass decides which methods work.

ModeOptionWhere it runsWhat it can reach
ChannelsalesChannelId: 'vc_…'Browser or serverThe full storefront surface, plus channel-only extras. The default.
StorestoreId: 'store_…'Browser or serverPublic catalog plus cart, checkout, orders and customer registration
AdminapiKey: 'brainerce_…'Server onlyCatalog writes, orders, coupons, settings, everything in the dashboard

Precedence. If you supply more than one, apiKey wins, then salesChannelId, then storeId. See isAdminMode() / isSalesChannelMode() / isStorefrontMode() below. Supplying none throws at construction.

Store mode is not read-only. It can create carts, run a checkout, place an order and register a customer. What it cannot do is administer the store.

Store mode cannot take money. The entire payment surface, meaning getPaymentProviders(), createPaymentIntent(), getPaymentStatus(), confirmSdkPayment() and waitForOrder(), is sales-channel only. A storeId client can reach completeCheckout() and produce an order, but it can never charge for it, so the only orders it can create are unpaid ones. Store mode also cannot reach search suggestions, checkout-level coupons, inventory reservations, the category / brand / tag lists, or the sitemap and slug-redirect helpers.

salesChannelId is the mode the storefront feature checklist assumes. Pick storeId only for a read-mostly surface that hands checkout off elsewhere.

connectionId is a deprecated alias of salesChannelId, kept working until SDK 2.0. It logs a deprecation warning on every construction. Use salesChannelId.

Full detail, including which credential is safe to ship to a browser, is in Authentication.

Mode labels used on this page

Every table below has a Modes column:

  • Channel: works with salesChannelId
  • Store: works with storeId
  • Admin: works with apiKey
  • Local: no network call; pure client-side state or a computed value

Calling a method outside its modes fails two different ways. Some methods guard explicitly and throw a BrainerceError naming the mode they wanted, such as getMyCart is only available in storefront mode. The rest have no guard: the SDK builds a URL that mode does not serve and you get a bare HTTP 404.

That second case is the trap. A 404 reads as "no such record", so it sends you hunting for a bad id. If a method 404s and the Modes column below does not list your credential, that is a mode error, not a missing record.

Constructor options

new BrainerceClient({
  salesChannelId?: string;   // 'vc_…'  — sales channel mode
  storeId?: string;          // 'store_…' — public storefront mode
  apiKey?: string;           // 'brainerce_…' — admin mode, server only
  connectionId?: string;     // deprecated alias of salesChannelId
  baseUrl?: string;          // default 'https://api.brainerce.com'
  timeout?: number;          // default 30000 (ms)
  origin?: string;           // needed server-side; auto-derived from baseUrl
  proxyMode?: boolean;       // BFF pattern: skip client-side token handling
  onAuthError?: (e: { message: string; statusCode: number; path?: string }) => void;
  onCartReset?: (i: { previousCartId: string; reason: 'not_found' | 'not_active' }) => void;
  publicCache?: false | { ttlMs?: number; staleIfErrorMs?: number; maxEntries?: number };
});

onCartReset is your only signal that the SDK silently replaced an unresolvable guest cart with a fresh empty one. Surface it, or shoppers watch their cart empty with no explanation.

publicCache controls the in-memory cache for public, static reads (content, regions, store info, categories, brands, tags, payment and shipping option lists). It is on by default (60 seconds), shared by every client in the process, and exists because a server-rendered storefront builds a client per request and server-side fetch has no HTTP cache, so every page view repeated the same reads and eventually hit the per-IP rate limit (HTTP 429). Products, prices, stock, carts and writes are never cached (identical anonymous product reads running at the same moment share one request, nothing is stored). Discount banners are kept for 30 seconds and search suggestions for 10. The options are ttlMs (fresh window, default 60000; 0 turns the cache off), staleIfErrorMs (how long the last good value may stand in for a failed refresh, default 600000, 0 disables the stand-in) and maxEntries (default 300, oldest dropped first). Pass false to turn it off; call client.clearPublicCache() (for example from a content.* webhook) to show a merchant edit at once.


Conventions that apply everywhere

Methods are flat on the client. client.getProducts(), not client.products.list(). There are exactly seven sub-namespaces (client.content, client.blog, client.contactForms, client.marketing, client.newsletterBenefit (admin mode only), client.stockAlerts and, from SDK 3.16.0, client.wishlist), documented in their own sections below. Nothing else is nested.

Prices are strings. basePrice, salePrice, price, subtotal, total, amount are all decimal strings like "99.99". parseFloat() before any arithmetic or comparison. Never number.

Paginated reads return { data, meta }:

{ data: T[]; meta: { page: number; limit: number; total: number; totalPages: number } }

Defaults are page=1, limit=20; the server caps limit at 100. See Pagination.

Errors throw BrainerceError, which carries message, statusCode and details. Codes and their meanings are in the Error Catalog; limits are in Rate Limits.

Most methods are async. The handful that are synchronous are called out in their tables, and you should not await them.


Session and mode introspection

All of these are Local, with no network call.

MethodPurpose
isAdminMode(): booleanTrue when constructed with apiKey
isSalesChannelMode(): booleanTrue when constructed with salesChannelId and no apiKey
isStorefrontMode(): booleanTrue when constructed with storeId only
isVibeCodedMode(): booleanDeprecated alias of isSalesChannelMode()
setCustomerToken(token: string | null): voidAttach a logged-in customer's token
getCustomerToken(): string | nullRead the attached token
clearCustomerToken(): voidDetach it
isCustomerLoggedIn(): booleanWhether a customer token is attached
setLocale(locale: string | undefined): voidSet the locale sent with every read
getLocale(): string | undefinedRead it back
getStoreDirection(locale?: string | null): 'ltr' | 'rtl'Document direction for <html dir>

setCustomerToken() is a plain setter. Always follow it with await client.syncCartOnLogin(), or the shopper's guest cart is never attached to their account, and identity-keyed features (first-order discounts, per-customer usage caps, abandoned-cart recovery) misbehave. The cart it resolves with is the account's cart after the merge, and its id can differ from the guest cart's: render that cart, and refresh your cart UI once it resolves. Do not fire it without await beside a cart refresh.

getSupportedLocales(): Promise<string[]> is async. It derives the list from getStoreInfo(), and returns [storeLanguage] when multi-language is off. See Translations.


Part 1: The mandatory storefront flow

Ordered the way a store gets built. Every feature the platform requires of a finished storefront appears in this part.

1. Store info and tracking

MethodPurposeModes
getStoreInfo(): Promise<StoreInfo>Name, currency, language, logo, i18n settings, SEO tokens. In Channel mode also channelLogo (the channel's own picture, null when unset): show channelLogo ?? logoChannel, Store
getStoreCapabilities(): Promise<StoreCapabilities>What the merchant configured on this channel: store, connection, featuresChannel
initTracking(tracking?: StoreTracking | null): voidWire GA4 / GTM / Meta / TikTok tags from store settings. SyncLocal
trackMarketingEvent(name, payload?): voidEmit a marketing event to the wired tags. SyncLocal
trackEvent(payload?: TrackEventPayload): Promise<void>First-party analytics beaconLocal
loadGoogleAnalytics(measurementId, options?): voidInject the GA script directly. SyncLocal
updateConsent(consent: { analytics?: boolean; ads?: boolean }): voidRecord a shopper's Consent Mode choice and push it to every wired tag. SyncLocal

Consent Mode v2 default. initTracking() (and loadGoogleAnalytics() called on its own) pushes a gtag('consent', 'default', ...) before any tag loads: denied for the EEA, UK and Switzerland, granted everywhere else — so an existing store with no consent banner sees no behavior change outside those regions. Brainerce ships no banner UI. Call updateConsent() from your own cookie-consent banner (or a GTM-based CMP's callback) once a shopper decides; a merchant in a denied-by-default region who never adds one gets no analytics storage there until they do.

The analytics and ads purposes are forwarded to the backend independently (analyticsConsentGranted and adsUserDataConsentGranted on setCheckoutCustomer/setShippingAddress), since each gates a different server-side send: analyticsConsentGranted for GA4, adsUserDataConsentGranted for a Google Ads Enhanced Conversions upload where that is configured. A shopper can grant one and decline the other.

getStoreInfo().seo carries indexNowKey and googleSiteVerification, both needed by the SEO section below. Two further fields are sales-channel mode only and come back undefined otherwise: shipping (flat-rate and free zones, for Product JSON-LD shippingDetails) and tracking (the marketing tag ids to hand straight to initTracking()).

ucp is a third sales-channel-only field: the Google cart transfer profile ({ profile: UcpProfile }), present only when the merchant switched Google cart transfer on and the channel has a domain. A storefront serves it verbatim at GET /.well-known/ucp. That route is a storefront file, not an SDK feature, so upgrading brainerce does not add it. See Google cart transfer (UCP).

getStoreInfo() is not usable in admin mode. There is no StoreInfo read behind an apiKey: GET /api/v1/store returns only { id, accountId, connectedPlatforms }, so the SDK throws a BrainerceError 400 rather than hand back a shape that looks like StoreInfo but isn't. Call it from a salesChannelId or storeId client only.

getStoreCapabilities() is the method for the capabilities payload. It calls GET /api/vc/{salesChannelId}/capabilities for you, so do not hand-roll a fetch. AI agents get the same payload from the get-store-capabilities MCP tool. getStoreInfo() overlaps with its store and connection blocks but never carries features, so the feature switches (payment providers, OAuth providers, shipping zones, coupons, downloadable products) come from here or from the feature-specific methods listed further down this page. Sales-channel mode only: in storeId or admin mode there is no single channel to read, so the call throws a 400 BrainerceError and you should use the per-feature methods instead. Call it once for the whole app and share the result; it is per channel, not per product. See Discover store capabilities.

2. Browse, filter and search products

MethodPurposeModes
getProducts(params?: ProductQueryParams): Promise<PaginatedResponse<Product>>Paginated catalog with category / brand / tag / price / metafield filters & sortChannel, Store, Admin
getProduct(productId, options?: { locale?, regionId? }): Promise<Product>One product by idChannel, Store, Admin
getProductBySlug(slug, options?: { locale?, regionId? }): Promise<Product>One product by slug, the PDP callChannel, Store
getSearchSuggestions(query: string, limit?: number): Promise<SearchSuggestions>Autocomplete: matching products + categoriesChannel only
getPublicMetafieldDefinitions(): Promise<{ definitions: PublicMetafieldDefinition[] }>Custom-field definitions visible to the storefrontChannel, Store, Admin
getMetafieldFilters(params?: { locale? }): Promise<MetafieldFiltersResponse>Facet values with product counts, for filter UIChannel, Store

getProducts({ ids }) (SDK 3.16.0 and newer, Channel and Store only) returns exactly those products: up to 100 distinct ids, duplicates ignored. An id that is unknown, belongs to another store or is not published is absent from the answer, not an error; an empty array returns an empty page without a request; more than 100 throws. Without a limit it defaults to the number of ids. It is what client.wishlist.resolve() calls (see section 22).

getSearchSuggestions() throws in storeId and apiKey mode. If you are building on a public storeId, filter with getProducts({ search }) instead.

getProducts() filters less in admin (apiKey) mode. GET /api/v1/products honours page, limit, search, status, type, sortBy, sortOrder and regionId only. categories, brands, tags, minPrice, maxPrice and metafields are ignored there, with no error: the unfiltered page comes back. Filter those client-side in admin mode.

Debounce autocomplete at ~300 ms with a 2-character minimum, because the endpoint is rate limited.

3. Product detail

Pure helpers, imported from the package (not methods on the client):

ExportPurpose
getProductPriceInfo(product)Price, sale price, whether a sale is active, discount percent
getProductPrice(product)Effective numeric price
getVariantPrice(variant, productBasePrice)Effective numeric price for a variant (sale only inside its window)
isSalePriceActive(productOrVariant, now?)true only while a salePrice is inside its sale window
formatPrice(priceString, options?)Locale-aware money string (alias: getPriceDisplay)
formatProductPrice(product, options?)Prefers the region-converted display* fields when present
formatVariantPrice(variant, options?)Same, for a variant
formatMoney(amount: number, currency: string, locale?)Raw money formatting
getStockStatus(inventory, options?)In stock / low stock / out of stock, with text
getVariantOptions(variant)Buyer-visible option pairs, internal keys stripped
getProductSwatches(product)Swatch groups for colour / size pickers
getDescriptionContent(product)Returns { html } or { text }. Never guess the format
isHtmlDescription(product)Whether the description is HTML
stripHtml(html)Plain-text fallback
deriveSeoDescription(product)Meta description with sensible fallbacks
getProductMetafield(product, key)One metafield row
getProductMetafieldValue(product, key)Just its value
getProductMetafieldsByType(product, type)All metafields of a type
getProductCustomizationFields(product)Buyer-input field definitions

Always sanitize before injecting. getDescriptionContent() can return raw merchant HTML, and the server does not pre-sanitize it. Some merchants embed iframes on purpose.

getStockStatus(inventory) has no answer for a KIT. A kit carries no inventory object at all, so passing product.inventory hands the helper undefined and you get an "in stock" reading for a kit that may be sold out. Branch on product.type === 'KIT' and read kitAvailable instead: null = unlimited, 0 = not sellable, any other number = that many kits.

Kits (type: 'KIT') on a product page

A kit is one purchasable product assembled from other catalog products, and the PDP has to do three things differently:

  • Add the kit itself, once. smartAddToCart({ productId: kit.id, quantity }) with the kit's own id and no variantId (a kit has no variants, and sending one is rejected). Never add the components as separate lines: that charges the customer twice and reserves the stock twice.
  • Read kitAvailable, not inventory. There is no inventory block on a kit. null = unlimited, 0 = not sellable (including a kit with no components). Treating a missing inventory as "in stock" is the common bug and it renders a sold-out kit as buyable.
  • Render kitComponents. It comes back on the single-product (by slug) read only, never on list responses, as { productId, variantId, name, sku, quantity, image } rows. It is display only: show the shopper what is in the box, do not turn the rows into cart lines or price them yourself.

Price: a kit priced FIXED uses its own basePrice/salePrice, but on SUM and SUM_MINUS_PERCENT the price is recomputed from the components on every read, so the stored basePrice is a placeholder. Use the price on the by-slug read; checkout recomputes it authoritatively and snapshots the result onto the cart line.

⛔ salePrice is only meaningful on a FIXED kit. On the two SUM modes the discount is already expressed by the pricing mode and the kit's own sale price is ignored by the pricing engine, so a strikethrough drawn from it advertises a discount that is not being applied. Both reads now behave the same way: outside FIXED they null the kit's salePrice, and on FIXED they leave basePrice/salePrice untouched so the normal was/now logic works. Gate any strikethrough on product.kitPricingMode === 'FIXED'.

Locale alternates for hreflang tags:

MethodPurposeModes
getProductAlternates(productId): Promise<{ alternates: Array<{ locale, slug }> }>Per-locale slugs for <link rel="alternate">Store only

4. Product reviews

MethodPurposeModes
listProductReviews(productId, params?: { page?, limit?, sort?: 'photos_first' | 'newest' }): Promise<PaginatedResponse<ProductReview>>Review list, no login needed. Each review carries images; reviews with photos lead by default. Throws 403 details.code === 'REVIEWS_DISABLED' while the merchant has reviews off; read getStoreInfo().reviewsEnabled firstChannel, Store
getMyProductReview(productId): Promise<MyProductReview>Drives the form state: eligible / already reviewed / not a purchaserChannel, Store
submitProductReview(productId, input: WriteProductReviewInput): Promise<ProductReview>Post a review (purchaser only)Channel, Store
updateMyProductReview(productId, input: WriteProductReviewInput): Promise<ProductReview>Edit your ownChannel, Store
deleteMyProductReview(productId): Promise<void>Delete your ownChannel, Store
uploadReviewPhoto(productId, file: File | Blob): Promise<ReviewPhotoUpload>Upload one photo, then pass its key in imageKeys on submitChannel, Store

uploadReviewPhoto needs setCustomerToken() and an eligible order, same as writing the review, and both are checked before the file is stored. It returns a key; imageKeys takes keys, never urls. On update imageKeys replaces the photo set, omitting it leaves photos untouched, and [] clears them. On stores with photo approval the submit response's images is empty, so re-fetch getMyProductReview() and render myImages to show the customer their pending upload.

The last four review methods need setCustomerToken() first. Submission is rate limited to 3 per 60 s per IP. product.avgRating and product.reviewCount are denormalized onto every product read, so emit aggregateRating in your Product JSON-LD only when reviewCount > 0 and getStoreInfo().reviewsEnabled !== false (the rollups keep their stored values while reviews are off).

Moderation is admin-side. See Part 2.

5. Buyer-input customization fields

MethodPurposeModes
uploadCustomizationFile(file: File | Blob, options?: { fieldKey?: string }): Promise<{ url: string; key: string }>Upload an engraving photo or gallery image. The options argument needs SDK 3.10.0+. Pass the field's key as fieldKey so the merchant's per-field print-quality setting applies; without it the file is stored as a regular image (WebP, 2048px max)Channel, Store

Read the definitions from product.customizationFields (already merged server-side, so never union anything yourself), render one control per field sorted by position, and submit the values keyed by field.key inside smartAddToCart({ productId, quantity, metadata }).

6. Cart

The smart cart is the supported surface: it picks the right cart for a guest or a logged-in customer and remembers the id for you.

MethodPurposeModes
smartAddToCart(item): Promise<Cart>Add by productId (+ variantId, quantity, metadata, modifier selections, nestedByModifierId). For a guest who already holds a cart it is one request (3.11.0+); the response is the whole updated cartLocal
smartGetCart(options?: CartIncludeOptions): Promise<CartWithIncludes>Fetch the current cartLocal
smartUpdateCartItem(productId, quantity, variantId?, options?: { itemId? }): Promise<Cart>Change a quantity. Pass { itemId } to change exactly that line (3.11.0+)Local
smartRemoveFromCart(productId, variantId?, options?: { itemId? }): Promise<Cart>Remove a line. Pass { itemId } to remove exactly that line (3.11.0+)Local
getSmartCartItemCount(): numberBadge count, in units (the sum of line quantities). Synchronous. It follows id-based writes on the guest session cart and returns 0 for a logged-in customer: there, read the count from useCartItemCount() or smartGetCart().itemCountLocal
syncCartOnLogin(): Promise<Cart>Attach the guest cart to the customer after login. Resolves with the account's cart after the mergeLocal
onLogout(): voidDrop the cached customer cart. SyncLocal
onCheckoutComplete(): voidReset cart caching after a completed order. SyncLocal

These are marked Local because they hold client-side state and then delegate to the server-cart methods below.

Every cart write answers with the whole updated cart: render it, do not refetch. A refetch is a second round trip for data you already have, and one that lands after the shopper's next tap paints an older cart over a newer one. Without itemId, smartUpdateCartItem and smartRemoveFromCart find the line by product and variant, which is the first line when several share a product (modifiers, notes, customization), so pass { itemId: line.id }:

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

A remove of a line that is already gone returns the current cart. An update of one throws a 404 BrainerceError with code === 'CART_ITEM_NOT_FOUND'.

error.code (3.11.0+). BrainerceError.code is the response body's stable machine code (a getter for error.details.code; undefined when there was no response body). Branch on it, never on message:

error.codeStatusWhat the SDK does
CART_NOT_FOUND404Resolves the cart (an ABANDONED one is revived in place, a converted or merged one is replaced and onCartReset fires) and retries once
CART_ITEM_NOT_FOUND404Remove: returns the current cart. Update: throws it to you
PRODUCT_NOT_FOUND, VARIANT_NOT_FOUND404Throws untouched. The cart is fine
CART_ACCESS_DENIED403Throws. The guest cart's proof was missing or wrong (see below). Never a login problem, so do not clear the customer session
NOT_FOUND or no code (an older backend)404Reads the cart once to find out which, then behaves as above

A failure that says nothing about the cart (no response, 408, 429, 5xx) is thrown as is, the session is kept and the write is not replayed (the line may already be in): show an error and let the shopper try again.

Guest cart ownership proof (cartToken). For a guest cart the SDK appends the cart's session token as the cartToken query parameter on every request under /cart/:cartId, on POST /checkout, on the checkout's coupon routes and on POST /reservation/extend and /release, and on nothing else (never on creating a cart, /cart/session/:token, merge, recover, link, the customer's own cart or the admin API-key surface). It sends it whenever it holds a guest session token, also while the shopper is signed in and until that cart is linked. Nothing to configure. It is a query parameter so that it survives a same-origin proxy that forwards the query and drops headers: if you proxy or call these routes yourself, pass the query string through, and redact cartToken from your logs. See Core, Cart ownership proof.

getHeldCartProof() returns that same held guest cart as { cartId, cartToken }, or null when the client holds none, and always null for a signed-in shopper's customer cart (the token is dropped after a login link or merge). It exists for one use: the storefront bot widget's getCartProof option, which lets the assistant change the cart on the server on a channel Brainerce switched on for that. cartToken is a secret, so send it only to the bot chat endpoint of the same store: never to a third party, a log, an analytics event, a URL or the DOM. See Storefront bot, the assistant changes the cart.

Abandoned-checkout links. The recovery email lands the shopper on /cart?brainerce_cart=<id>#brainerce_recover=<code>. The SDK reads both at construction, strips them from the address bar and exchanges the one-time code for the cart on the first smartGetCart(). Do not read ?brainerce_cart yourself; it is already gone once the client exists.

Cart store (createCartStore, brainerce/react)

Hand-written cart state is where flashes of an empty cart, lost taps and stale refetches come from. createCartStore(client, options) (from brainerce, SDK 3.11.0+) is a headless store with no markup and no framework dependency, and brainerce/react binds it to React (React 18+ is an optional peer dependency).

import { BrainerceClient, createCartStore } from 'brainerce';

const store = createCartStore(client, { scope: 'vc_YOUR_SALES_CHANNEL_ID' });
store.start(); // module level: the load begins before hydration; a no-op on the server
Snapshot fieldMeaning
statusloading, empty (only after the server said so), ready, error (the first load failed; retry() leaves it)
cartThe optimistic view: the last confirmed cart with pending quantity and removal changes applied. null while loading or error
itemCount, hintCountUnits in the view; the count remembered from the last visit (0 when unknown), for sizing a loading skeleton only, with the row count capped
isSyncing, pendingCount, totalsStaleA write is queued or in flight; how many; totals do not include them yet. Totals are always the last server-confirmed ones, never computed here
canCheckoutready, nothing pending or syncing, no unavailable line, reservation not expired
loadError, mutationErrorThe last failed load or refresh; the last failed (already rolled back) write. dismissMutationError() clears the latter
epochBumped by reset(); work started under an older epoch can never touch newer state

Actions: add(input), setQuantity(itemId, quantity) (absolute; <= 0 removes), remove(itemId), applyCoupon(code), removeCoupon(), clear(), refresh({ force? }), retry(), loadEnrichment(include) (the cart page's single ?include= request), flush(), reset(reason), destroy(). Every action resolves, success or failure: failures land in mutationError and loadError, so there are no unhandled rejections. Taps are coalesced (one write in flight per cart, a burst on one line sends at most two requests) and each response is adopted as the new confirmed cart with no follow-up read. After the first add it writes with the id-based methods only. Before checkout:

const { ok } = await store.flush();
if (ok && store.getSnapshot().canCheckout) navigate('/checkout');

reset('login' | 'logout' | 'order-complete' | 'store-switch') drops the queue and pending changes and loads afresh; call it after the client reflects the change (setCustomerToken, syncCartOnLogin, onCheckoutComplete).

import { CartProvider, useCart, useCartItemCount } from 'brainerce/react';

<CartProvider store={store} authKey={customerId}>
  {children}
</CartProvider>;

const { status, cart, hintCount, setQuantity, remove, retry, flush, canCheckout } = useCart();
const count = useCartItemCount(); // header badge; re-renders only when the number changes

CartProvider takes a client or an existing store (when you pass store, put scope in createCartStore), and an authKey (the signed-in customer's id, or null) that resets the store on login and logout. For Vue, Svelte and Astro use store.subscribe(listener) and store.getSnapshot() directly. A full walkthrough with a React cart page is in Core, Use the cart store.

The store exposes a read-only debug handle window.__brainerce.cart (status, pending count, item count, epoch, writes sent, last error code: never tokens or cart bodies) that verify-storefront.mjs reads.

Server cart (low level)

MethodPurposeModes
createCart(options?: CreateCartOptions): Promise<Cart>Create an empty cartChannel, Store, Admin
getCart(cartId, options?: CartIncludeOptions): Promise<CartWithIncludes>Fetch by id, optionally with includesChannel, Store, Admin
getCartBySession(sessionToken): Promise<Cart>Resolve a guest cart from its session tokenChannel, Store, Admin
getHeldCartProof(): { cartId, cartToken } | nullThe guest cart this client holds right now (secret token, for the bot widget's getCartProof); null for a signed-in shopper's cart. Needs a release newer than 3.18.0Channel, Store
addToCart(cartId, item: AddToCartDto): Promise<Cart>Add a lineChannel, Store, Admin
updateCartItem(cartId, itemId, data: UpdateCartItemDto): Promise<Cart>Change a lineChannel, Store, Admin
removeCartItem(cartId, itemId): Promise<Cart>Remove a lineChannel, Store, Admin
clearCart(cartId): Promise<Cart>Empty the cartChannel, Store, Admin
linkCart(cartId): Promise<Cart>Attach the cart to the logged-in customer; merges into an existing account cart and returns that cartChannel, Store
getMyCart(): Promise<Cart>The signed-in customer's cartStore only
recalculateCart(cartId): Promise<Cart>Re-price the cart against current product pricesChannel only
refreshCartSnapshots(cartId): Promise<Cart>Refresh per-item unitPrice snapshots to live pricesChannel only
getCartByCustomer(customerId): Promise<Cart>A given customer's cartAdmin only
mergeCarts(data: MergeCartsDto): Promise<Cart>Merge a guest cart into a customer cartAdmin only

Totals: use the exported getCartTotals(cart, shippingPrice?) helper, plus getCartItemName(item) and getCartItemImage(item) for rendering lines.

⛔ addToCart caps a shopper cart at 50 distinct lines. In Channel and Store mode, adding a 51st different product throws a 400 carrying the error code CART_LINE_LIMIT_REACHED. Admin mode is not capped, because a B2B or bulk-import cart legitimately runs long. The cap counts distinct lines, not quantity, and only fires when a new line would be created, so an already-full cart can still have quantities changed and items removed. Render it as a "cart is full" state rather than a generic error. See Cart limits.

Detect it by the code. The SDK puts the whole parsed error body on BrainerceError.details, so the code sits one level in, with the limit beside it:

catch (err) {
  const body = (err as BrainerceError).details as
    { code?: string; details?: { maxLines?: number } } | undefined;
  if (body?.code === 'CART_LINE_LIMIT_REACHED') showCartFull(body.details?.maxLines ?? 50);
}

⛔ Keep any existing match on the message as a fallback. The message text is byte-for-byte unchanged ("A cart can hold at most 50 different items. Remove something before adding more.") precisely so that storefronts written before the code existed keep working, and so that a storefront pointed at a backend that has not been redeployed yet still recognises a full cart. Branch on the code first; fall through to the prose match.

7. Coupons

MethodPurposeModes
applyCoupon(cartId, code): Promise<Cart>Apply a code on the cart pageChannel, Store, Admin
removeCoupon(cartId): Promise<Cart>Remove itChannel, Store, Admin
applyCheckoutCoupon(checkoutId, code): Promise<Checkout>Apply on the checkout pageChannel, Admin
removeCheckoutCoupon(checkoutId): Promise<Checkout>Remove itChannel, Admin

Once a checkoutId exists, use the checkout pair. The exported isCouponApplicableToProduct(...) helper answers per-product eligibility for badge rendering.

A gift card is not a coupon and is not here: it is a means of payment, so it never touches discountAmount and never moves checkout.total. See Gift cards under Checkout.

Discount display (rules, not codes):

MethodPurposeModes
getDiscountBanners(): Promise<DiscountBanner[]>Active promotion banners for the headerChannel, Store
getProductDiscountBadge(productId): Promise<ProductDiscountBadge | null>Badge for a product card / PDPChannel, Store
getCartNudges(cartId): Promise<CartNudge[]>"Add $8 more for free shipping" promptsChannel, Store

Build these even when the store has no active discounts, because they render nothing until one exists.

8. Stock, availability and the reservation countdown

MethodPurposeModes
checkCartStock(cart, selectedItemIds?): Promise<StockAvailabilityResponse>Pre-flight the whole cart (pass the cart object, or its id); wraps the Channel-only primitive belowChannel only
checkStockAvailability(items: StockAvailabilityRequest[]): Promise<StockAvailabilityResponse>The primitive: check specific itemsChannel only
getAvailability(productIds: string[]): Promise<ProductAvailability[]>Available vs reserved quantities per productChannel only
extendReservation(options: { cartId?, checkoutId? }): Promise<ExtendReservationResponse>Keep a reservation aliveChannel only
releaseReservation(options: { cartId?, checkoutId? }): Promise<void>Hand stock back immediatelyChannel only

When the store keeps stock in more than one warehouse, available is the most one active warehouse can ship in a single line, because a line is never split across warehouses. It can be lower than the product's total stock, and the cart and checkout refuse a larger line with INSUFFICIENT_STOCK.

The countdown itself is driven by the reservation timestamps already on the Cart object returned by getCart() / smartGetCart(). Do not run your own timer logic against your own clock.

Kits are the exception to "read inventory". A KIT product has no inventory object and no stock row of its own. Its sellable quantity is on kitAvailable: null = unlimited, 0 = not sellable, any other number = how many kits can be sold, decided by whichever component runs out first. It can therefore drop without the kit itself being touched, and a page that only looks at inventory shows a sold-out kit as buyable. getStockStatus() does not cover this case; branch on product.type === 'KIT' before calling it.

9. Upsells, bundles, order bumps and recommendations

Ten methods that had no SDK-level documentation before this page, plus the three recommendation measurement calls (they need brainerce 3.17.0). All Channel, Store.

MethodPurpose
getProductRecommendations(productId, type?: ProductRelationType): Promise<ProductRecommendationsResponse>The merchant's manual cross-sells, upsells and related products for a PDP, plus automatic: ONE group from purchase history (bought_together or similar), and customersAlsoBought: a separate group of what customers who bought it went on to buy in other orders (no fallback)
getCartRecommendations(cartId, limit?: number): Promise<CartRecommendationsResponse>{ recommendations, automaticRecommendations? }: the manual list, then a separate automatic group. limit caps both together
observeRecommendation(element, card, options?): () => voidOne impression per card per page load (50% visible for 1 second). Returns an unobserve function; a no-op without a recToken
trackRecommendationClick(card): Promise<TrackRecommendationEventsResult>A click on a card's link, sent with keepalive. Never throws
trackRecommendationEvents(events): Promise<TrackRecommendationEventsResult>The lower-level call: { type, token, eventId? }[], max 50 per request, retried with the same ids, never throws
getCartUpgrades(cartId): Promise<CartUpgradesResponse>Upsell swaps with a small price delta ("go large for $2")
getCartBundles(cartId): Promise<CartBundlesResponse>Bundle offers matching the current cart, with bundle pricing
addBundleToCart(cartId, bundleOfferId, variantSelections?: Record<string, string>): Promise<Cart>Accept a bundle, adding every offered product not yet in the cart at the bundle discount. Pass productId → variantId for offered products that have variants
removeBundleFromCart(cartId, bundleOfferId): Promise<Cart>Back the bundle out
getCheckoutBumps(checkoutId): Promise<CheckoutBumpsResponse>One-click add-ons for the checkout page
addOrderBump(cartId, bumpConfigId, variantId?): Promise<Cart>Accept a bump
removeOrderBump(cartId, bumpConfigId): Promise<Cart>Remove it

Note the asymmetry that trips people up: bumps are fetched with a checkoutId but added and removed against the cartId.

10. Checkout

MethodPurposeModes
createCheckout(data: CreateCheckoutDto): Promise<Checkout>Open a checkout from a cart (cartId, optional customerId, selectedItemIds, regionId)Channel, Store, Admin
getCheckout(checkoutId): Promise<Checkout>Re-read itChannel, Store, Admin
setCheckoutCustomer(checkoutId, data: SetCheckoutCustomerDto): Promise<Checkout>Attach customer details and order notesChannel, Store, Admin
setShippingAddress(checkoutId, address: SetShippingAddressDto): Promise<SetShippingAddressResponse>Step 1. Returns { checkout, rates }Channel, Store, Admin
getShippingRates(checkoutId): Promise<ShippingRate[]>Re-read the rates for the saved addressChannel, Store, Admin
selectShippingMethod(checkoutId, shippingRateId): Promise<Checkout>Pick a rateChannel, Store, Admin
getPickupPoints(checkoutId, search?: PickupPointSearch): Promise<PickupPoint[]>Carrier pickup points near the address, nearest first. Needs features: ['pickup-points']
selectPickupPoint(checkoutId, point): Promise<Checkout>Choose one. Payment is refused with PICKUP_POINT_REQUIRED until a pickup rate has a point
setBillingAddress(checkoutId, address: SetBillingAddressDto): Promise<Checkout>Billing, with sameAsShipping supportChannel, Store, Admin
completeCheckout(checkoutId, options?: { idempotencyKey? }): Promise<CompleteCheckoutResponse>Finalize, returning { orderId }. Admin mode sends Idempotency-Key (generated if omitted)Channel, Store, Admin
deleteCheckout(checkoutId): Promise<{ success: boolean }>Abandon it and release the inventory reservationChannel, Store, Admin
getCheckoutCustomFields(checkoutId): Promise<CheckoutCustomFieldDefinition[]>Store-specific extra fields (tax ID, delivery notes…)Channel, Store
setCheckoutCustomFields(checkoutId, fields: Record<string, unknown>): Promise<Checkout>Submit their valuesChannel, Store
getShippingDestinations(): Promise<ShippingDestinations>Countries and regions the store ships toChannel only

Email is required on setShippingAddress, because it is what the order confirmation goes to. Render checkout.lineItems on the summary, not cart.items.

createCheckout creates the checkout from the cart as the server holds it, so settle the cart first: const { ok } = await cartStore.flush(); and call it only when ok (otherwise the shopper's last tap is not in the checkout). For a guest cart it carries the cartToken proof like every other cart write; a 403 CART_ACCESS_DENIED means the proof was missing, not that the shopper must sign in.

Checkout inputs need autofill attributes. Give every field an autocomplete token, a name, and for email and phone an inputmode, so a phone can fill the saved address and shows the right keyboard: email (inputmode="email"), given-name, family-name, tel (inputmode="tel"), address-line1, address-line2, address-level2 (city), address-level1 (state), postal-code (no numeric inputmode: postal codes contain letters) and country. A typeahead line1 field uses autocomplete="off" so the browser's saved-address list does not open over yours. Prefix the tokens with shipping or billing when one page holds both blocks. Table and reasoning: Core, Checkout input attributes.

A checkout expires 30 minutes after the last saved step (every customer, address, delivery or shipping save restarts the clock). Read checkout.expiresAt for the deadline.

Gift cards (a tender, not a discount)

Conditional on getStoreCapabilities().features.hasGiftCards — which is readable in Channel mode only, like the rest of that payload. getStoreInfo() carries no gift-card flag, so a Store-mode or Admin integration has no switch to read: build the field and let an unusable code be refused.

MethodPurposeModes
applyGiftCard(checkoutId, code): Promise<CheckoutTender>Apply a card to the checkoutChannel, Store, Admin
removeGiftCard(checkoutId, tenderId): Promise<{ removed, providerAmountDue }>Remove one, by tenderIdChannel, Store, Admin
checkGiftCardBalance(code): Promise<GiftCardBalance>{ balance, currency, usable } — optional pre-checkChannel, Store, Admin

Applying a card does not change checkout.total, and tax stays calculated on the full order value. What drops is checkout.providerAmountDue, the amount the payment provider will be charged. Render the card on its own line below the total, then an "Amount due" line — never inside the discount block, and never added to discountAmount.

Read applied cards from checkout.tenders ({ tenderId, amountApplied }[], oldest first) on every render. The hold lives on the server, so a storefront that only keeps the applyGiftCard response loses the card on a reload while it is still applied. Remove by tenderId, never by code: a checkout can carry several cards and the code is never echoed back.

Only what the order still owes is taken, so a card larger than the basket keeps the rest — show the amount applied, not the balance.

Every refusal is the same HTTP 400 with the same message. Unknown, expired, spent, disabled and wrong-currency are indistinguishable on purpose, because a response that told them apart is an oracle for walking the code space; a card also pays only in its own currency, with no conversion. checkGiftCardBalance answers identically for unknown, disabled and expired codes. Show one "we can't use this code" and let the shopper re-type it. Both routes are rate limited to 5 per minute.

Apply and remove before createPaymentIntent — afterwards both fail with CHECKOUT_LOCKED. The intent's amount already nets live cards off server-side, so never subtract anything yourself; and when providerAmountDue is '0.00' there is no charge to make at all: skip the provider, call completeCheckout(checkoutId) — which returns { orderId }, so no waitForOrder poll — and then handlePaymentSuccess(checkoutId) to clear the cart.

Address autocomplete

MethodPurposeModes
getAddressSuggestions(query, sessionToken, near?: { lat, lng }): Promise<AddressSuggestion[]>Typeahead suggestionsChannel, Store
getAddressDetails(placeId, sessionToken, options?: { regionId? }): Promise<AddressDetailsResult>Resolve a suggestion to a full addressChannel, Store

Pass placeId through to setShippingAddress(). The server re-resolves it to exact coordinates and matches polygon ("draw on map") shipping zones against those. Re-geocoding the address text is materially less accurate and can place the shopper in a neighbouring city's zone, or in none at all. Use the same sessionToken for the suggestion and the details call.

Pickup instead of delivery

MethodPurposeModes
getPickupLocations(): Promise<PickupLocation[]>The store's pickup pointsChannel, Store
setDeliveryType(checkoutId, deliveryType: 'shipping' | 'pickup'): Promise<Checkout>Switch the modeChannel, Store
selectPickupLocation(checkoutId, data: SelectPickupLocationDto): Promise<Checkout>Choose a pointChannel, Store

11. Payment

MethodPurposeModes
startPayment(checkoutId, options: StartPaymentOptions): PaymentSessionThe whole payment step, for every provider. Creates the intent and shows it. Browser onlyChannel only
completePaymentReturn(checkoutId, options?: { search?, maxWaitMs? }): Promise<PaymentReturnResult>On /order-confirmation: hand the provider's return parameters to the server and wait for the orderChannel only
getPaymentProviders(): Promise<PaymentProvidersConfig>Every installed provider plus the default, which is what you renderChannel only
createPaymentIntent(checkoutId, options?): Promise<PaymentIntent>Start the charge. Options: providerId, successUrl, cancelUrl, saveCard, preferredRenderType, expectedTotalChannel only
getPaymentStatus(checkoutId): Promise<PaymentStatus>Poll the charge stateChannel only
confirmSdkPayment(checkoutId, providerResponseData?: Record<string, unknown>): Promise<{ confirmed: boolean }>Confirm a client-side SDK payment after the provider returnsChannel only
getPaymentConfig(): Promise<PaymentConfig>Deprecated single-provider shortcut; use getPaymentProviders()Channel only
confirmGrowPayment(checkoutId, confirmationNumber?, growResponseData?)Deprecated; use confirmSdkPayment()Local

The whole payment surface is sales-channel-only. A storeId client cannot create a payment intent.

The payment step: startPayment

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

// Start from the shopper's click, never on page load (a redirect provider leaves at once).
const session: PaymentSession = client.startPayment(checkoutId, {
  container, // one element that stays mounted; wrap it in a modal for 'provider-page'
  onPresentation: (p) => setShowModal(p === 'provider-page'), // 'sandbox' → test-order button
  onStatus: (status) => setBusy(status === 'preparing' || status === 'processing'),
  onSuccess: ({ checkoutId: id }) => {
    window.location.href = `/order-confirmation?checkout_id=${encodeURIComponent(id)}`;
  },
  onFailure: (message) => setError(message), // declined or cancelled: session.reopen() to retry
  onError: (e: PaymentFlowError) => {
    if (e.code === 'session_locked')
      offerNewCheckout(); // go to /checkout without checkout_id
    else if (e.code === 'load_failed' && e.url) offerNewTab(e.url);
    else if (e.code === 'unconfirmed')
      setInfo('Confirming your payment. Your order will appear shortly.');
    else setError('Payment is unavailable right now.');
  },
});
// Sandbox: session.completeTestOrder(). Retry: session.reopen(). Unmount: session.destroy().

// /payment-complete (the page a payment iframe lands on) renders nothing else:
relayPaymentReturn();

// /order-confirmation:
const result = await client.completePaymentReturn(checkoutId);
// result.status: 'succeeded' (the order exists) | 'pending' (still confirming) | 'failed' | 'canceled'
ExportPurpose
startPayment(client, checkoutId, options): PaymentSessionStandalone form of client.startPayment
relayPaymentReturn(options?: { confirmationPath? }): voidFor /payment-complete: inside the payment iframe, tells the checkout page (same origin only); at the top level, forwards to /order-confirmation
completePaymentReturn(client, checkoutId, options?)Standalone form of client.completePaymentReturn
PaymentFlowErroronError's argument. code: 'unavailable' | 'session_locked' | 'blocked_url' | 'blocked_script' | 'load_failed' | 'provider_error' | 'unsupported' | 'unconfirmed' | 'invalid_checkout' | 'total_changed' | 'region_required'; url? (a new-tab fallback); totalChange? (total_changed only); cause?

StartPaymentOptions: container (required), onSuccess, onFailure, onError (required), onPresentation?, onStatus?, providerId? (express buttons), saveCard?, preferredRenderType?, expectedTotal? (the checkout.total on screen; a changed total is onError code total_changed, nothing charged), urls?: { success?, cancel?, iframeReturn? } (defaults /order-confirmation?checkout_id=..., /checkout?checkout_id=...&canceled=true, /payment-complete?checkout_id=...), extraPaymentHosts?, extraScriptHosts? (a custom self-hosted provider; they add to the allowlists, never remove). PaymentPresentation is 'widget' | 'redirect' | 'provider-page' | 'embedded' | 'sandbox' and PaymentFlowStatus is 'preparing' | 'redirecting' | 'open' | 'processing' | 'closed'. onSuccess({ checkoutId, orderId?, orderNumber? }) fires only after the server has the order. A 503 (no_active_payment_provider, PAYMENTS_PAUSED) arrives as onError with code: 'unavailable' and the BrainerceError on cause.

⛔ Never branch on clientSdk.renderType or on the provider name, never call a provider global (window.<providerGlobal>), and never build the payment iframe, its postMessage listener or the redirect yourself: startPayment does all of it, with every URL checked against PAYMENT_PAGE_HOSTS, every widget script against isAllowedPaymentScriptUrl, iframe messages accepted only from the payment iframe's own window and origin, and a completion handled once.

Stripe and PayPal are redirect providers (a hosted Checkout Session URL, returning with session_id; an approve URL, returning with token and PayerID). There is no Stripe Elements form, no publishableKey on the intent and no PayPal JS buttons. Which provider uses which render type is returned by the API (displayModes, renderMode, renderType); never branch on a provider name.

Without startPayment, branch on clientSdk.renderType, never on "does clientSdk exist". Every provider returns a clientSdk, including the sandbox one. The render types are 'sdk-widget', 'iframe', 'redirect' and 'sandbox' ('embedded-fields' is reserved; no provider produces it). For 'redirect' and 'iframe', the URL is clientSdk.renderArg with clientSecret as the fallback, in that order: clientSecret holds the provider's payment identifier and only some providers duplicate the URL into it, so reading it alone works for some providers and breaks on others. The full note is in Core Integration Guide, Step 5.3.

PaymentIntent.amount is a decimal string in the store or presentment currency ("499.80"), not cents.

createPaymentIntent({ providerId }) is how you wire an additive express button: take the id from a getPaymentProviders() entry. saveCard: true vaults the card, and is only honoured when the checkout has a known customerId. Offer the checkbox only when the provider's supportsSavedCards is true, and send saveCard only after the customer ticks it. The checkbox is unchecked by default and consents to the card being saved for future charges. The platform also drops saveCard on its own for a provider that cannot save a card. Payment providers require that consent. With some providers, a checkout that saves the card runs on a separate token page that takes credit cards only (no wallets or alternative payment methods), so show the checkbox before the payment form loads.

preferredRenderType: 'redirect' | 'iframe' asks for a presentation mode. It is honoured only when the provider lists that mode in its clientSdk.displayModes (on the getPaymentProviders() entry); otherwise the provider's default comes back and PaymentIntent.renderModeResolution is 'fallback'. Asking is never an error, and omitting it is what every storefront did before the option existed. Because an iframe intent returns the shopper inside the frame (to a same-origin page that posts to the parent) while a redirect intent returns them to your confirmation page, predict the mode before the call (startPayment does this for you) with resolveRenderType(provider.clientSdk, preferred) and pass the matching successUrl; then branch on the renderType that came back, never on what you asked for.

ExportPurpose
resolveRenderType(clientSdk, preferred?): RenderTypeThe platform's resolution rule, so the storefront can pick successUrl before the intent exists

Payment widgets (lower level)

startPayment opens a widget provider with these. Use them directly only when you are not using startPayment.

ExportPurpose
mountPaymentWidget(intent, handlers: PaymentWidgetHandlers, options?): PaymentWidgetHandleOpen the provider's payment window for the intent: load, init once, render, retry on transient start-up errors. Browser only
preloadPaymentWidget(clientSdk): Promise<void>Load and init the provider early. Call it when getPaymentProviders() answers, before createPaymentIntent. No-op for any other provider
paymentWidgetMessage(raw): stringThe provider's message from whatever shape it answered with
PAYMENT_WIDGET_TRANSIENT_ERRORSThe built-in "not ready yet, render again" messages. A provider adds its own via clientSdk.transientErrors

When clientSdk.renderType === 'sdk-widget' and you are not using startPayment, always open it with mountPaymentWidget. Never call that global's init or renderPaymentOptions yourself, and never treat "SDK was not loaded as needed and therefore can't run" or "Wallet not initialized" as a failure: a widget provider answers them while it is still loading, and the helper renders again (up to 4 attempts) before it reports onError. The intent's clientSdk is the complete descriptor, so the intent alone is enough.

import { mountPaymentWidget, preloadPaymentWidget } from 'brainerce';

const { defaultProvider } = await client.getPaymentProviders();
void preloadPaymentWidget(defaultProvider?.clientSdk); // before createPaymentIntent

const intent = await client.createPaymentIntent(checkoutId, {
  successUrl,
  cancelUrl,
  expectedTotal: Number(checkout.total), // 409 CHECKOUT_TOTAL_CHANGED if the tax moved
  // 400 TAX_ADDRESS_REGION_REQUIRED if US/CA/AU has no shipping `region` (isTaxAddressRegionRequiredError)
});
const sdk = intent.clientSdk;
if (sdk?.renderType === 'sdk-widget' && sdk.scriptUrl && sdk.globalName) {
  const widget = mountPaymentWidget(intent, {
    onSuccess: async (data) => {
      await client.confirmSdkPayment(checkoutId, data);
      window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
    },
    onFailure: (message) => showError(message || 'The payment was declined.'),
    onError: (message) => showError(message || 'The payment could not start.'),
    onOpen: () => {
      hideSpinner();
      clearError(); // a slow window can still open after onError
    },
  });
  // On unmount: widget.destroy();
}

PaymentWidgetHandlers also takes onTimeout, onClose, onPaymentStart and onPaymentCancel. MountPaymentWidgetOptions is { providerSdk?, maxRenderAttempts?, openTimeoutMs?, scriptTimeoutMs?, scriptHosts? } (defaults 4, 15000 ms, 20000 ms). A widget script loads only from a host isAllowedPaymentScriptUrl accepts; scriptHosts adds hosts for a custom self-hosted provider.

Package-level guards (startPayment already applies them):

ExportPurpose
isAllowedPaymentUrl(url, options?): booleanIs this URL on the payment page allowlist?
safePaymentRedirect(url, options?): voidNavigate only if it is; blocks open-redirect abuse
isAllowedPaymentScriptUrl(url, options?): booleanIs this widget script https and on the script allowlist? options.extraHosts adds hosts
PAYMENT_PAGE_HOSTSThe payment page allowlist (also what buildStorefrontCsp puts in frame-src)
buildStorefrontCsp({ nonce, apiOrigin, isDev? }): stringThe storefront CSP. frame-src covers every payment host plus apiOrigin (Brainerce embeds)

12. Order confirmation

MethodPurposeModes
handlePaymentSuccess(checkoutId?): { cleared, mode, userType, itemsRemoved? }Clear the right cart for guest or customer, full or partial checkout. SynchronousLocal
waitForOrder(checkoutId, options?: WaitForOrderOptions): Promise<WaitForOrderResult>Poll until the webhook has created the real orderChannel only
getOrderByCheckout(checkoutId): Promise<Order>Fetch the order once it existsChannel, Store

Both handlePaymentSuccess and waitForOrder are mandatory on the confirmation page. WaitForOrderOptions is { maxWaitMs?, onPollAttempt?, onOrderReady? }. Handle the timeout branch with a link to order history rather than an error.

Guest checkout

MethodPurposeModes
startGuestCheckout(options?: { selectedIndices?: number[] }): Promise<GuestCheckoutStartResponse>Open a guest checkoutChannel only
updateGuestCheckoutAddress(checkoutId, data: { shippingAddress?, billingAddress? }): Promise<Checkout>Set the addressesChannel only
completeGuestCheckout(checkoutId, options?: { clearCartOnSuccess?, selectedIndices? }): Promise<GuestOrderResponse>Finish itChannel only
getActiveGuestCheckout(): { checkoutId, cartId, selectedIndices? } | nullResume an in-flight guest checkout. SynchronousLocal
clearActiveGuestCheckout(): voidForget it without clearing the cart. SynchronousLocal

submitGuestOrder(), createGuestOrder() and createOrder() bypass payment entirely and produce unpaid orders. They exist for sandbox and cash-on-delivery scenarios. Never call them on a store with a live payment provider; use the checkout + payment-intent flow above.

Downloads for digital products

MethodPurposeModes
getOrderDownloads(orderId, options?: { checkoutId? }): Promise<OrderDownloadLink[]>Signed links for a signed-in buyerChannel, Store
getGuestOrderDownloads(email, orderNumber): Promise<OrderDownloadLink[]>Signed links for a guestChannel, Store

13. Customer authentication

MethodPurposeModes
registerCustomer(data: RegisterCustomerDto): Promise<CustomerAuthResponse>Create an account. Handle the requiresVerification branch. Accepts birthMonth / birthDayChannel, Store, Admin
loginCustomer(email, password): Promise<CustomerAuthResponse>Log in. Also returns requiresVerificationChannel, Store, Admin
forgotPassword(email): Promise<{ message: string }>Always answer generically, with no account enumeration. options.resetUrl is deprecated and ignored: the server derives the reset host itself, and sending the field is rejected with HTTP 400Channel, Store, Admin
resetPassword(token, newPassword): Promise<{ message: string }>Consume the emailed tokenChannel, Store, Admin
verifyEmail(code: string, token?: string): Promise<EmailVerificationResponse>Submit the 6-digit codeChannel only
resendVerificationEmail(token?: string): Promise<{ message: string; token?: string }>Resend itChannel only

Password policy. The API enforces at least 8 characters with one uppercase, one lowercase, one number and one special character on both registration and reset. A password that only satisfies minLength={8} is rejected with HTTP 400, so put the real rule in your input hint.

After a successful login or registration: setCustomerToken(auth.token) then await syncCartOnLogin(). A storefront with a wishlist (SDK 3.16.0 and newer) also calls await client.wishlist.syncOnLogin() here; see section 22.

OAuth (social login)

MethodPurposeModes
getAvailableOAuthProviders(): Promise<OAuthProvidersResponse>Which buttons to render, and redirectReady: whether a sign-in from this origin can return here (false = render none)Channel, Store
getOAuthAuthorizeUrl(provider: CustomerOAuthProvider, options?: { redirectUrl? }): Promise<OAuthAuthorizeResponse>Where to send the browserChannel, Store
handleOAuthCallback(provider, code, state): Promise<OAuthCallbackResponse>Exchange the callback params for a sessionChannel, Store
exchangeOAuthCode(authCode: string): Promise<OAuthCallbackResponse>Exchange the one-time auth_code the callback appends to your URL for the customer JWT. Keeps the token out of the address barAny mode
linkOAuthProvider(provider, options?: { redirectUrl? }): Promise<OAuthAuthorizeResponse>Attach a provider to an existing accountChannel, Store
unlinkOAuthProvider(provider): Promise<{ success: boolean }>Detach itChannel, Store
getOAuthConnections(): Promise<OAuthConnectionsResponse>Which providers this customer has linkedChannel, Store

CustomerOAuthProvider has exactly three members: 'GOOGLE', 'FACEBOOK', 'GITHUB' (uppercase in the SDK type and in the capabilities payload; the REST path segment is lowercase, /oauth/google/authorize). The admin-side OAuthProviderType used by the configuration methods in Part 2 is the same set.

Render the button region and wire the callback route even when no provider is enabled today. Both auto-hide, and enabling a provider should not require a code change.

14. Customer account

MethodPurposeModes
getMyProfile(): Promise<CustomerProfile>The signed-in customer's profile, including birthMonth / birthDayChannel, Store
updateMyProfile(data): Promise<CustomerProfile>firstName, lastName, phone, acceptsMarketing, birthMonth, birthDayChannel, Store
getCustomerProfile(): Promise<{ id, email, firstName?, lastName?, phone?, emailVerified, role? }>Deprecated: use getMyProfile(), same route with a fuller return type. Lighter profile read; role is the merchant's free-form segmentChannel, Store
getMyOrders(params?: { page?, limit? }): Promise<PaginatedResponse<Order>>Paginated order historyChannel, Store
getReturnEligibility(orderId): Promise<ReturnEligibility>What can still be returned from one order (SDK 3.18.0+)
requestReturn(input: RequestReturnInput): Promise<CustomerReturn>Ask to return items of a shipped order (SDK 3.18.0+)
getMyReturns(params?: { page?, limit? }): Promise<PaginatedResponse<CustomerReturn>>The customer's returns, newest first (SDK 3.18.0+)
getMyReturn(id): Promise<CustomerReturn>One return (SDK 3.18.0+)
cancelMyReturn(id): Promise<CustomerReturn>Cancel while it is still REQUESTED (SDK 3.18.0+)
getCheckoutPrefillData(): Promise<CheckoutPrefillData>Pre-fill the checkout form for a returning customerChannel, Store
getMyAddresses(): Promise<CustomerAddress[]>Saved addressesChannel, Store
addMyAddress(address: CreateAddressDto): Promise<CustomerAddress>Add oneChannel, Store
updateMyAddress(addressId, data: UpdateAddressDto): Promise<CustomerAddress>Edit oneChannel, Store
deleteMyAddress(addressId): Promise<void>Remove oneChannel, Store
getMySavedPaymentMethods(): Promise<StorefrontSavedPaymentMethod[]>Vaulted cards for this customerChannel, Store

Logout is clearCustomerToken() plus onLogout().

Birthday fields. birthMonth (1-12) and birthDay (1-31) carry no year, must be written together, and are returned by getMyProfile() and by getCheckoutPrefillData().customer, so pre-fill the picker rather than rendering it empty. Both are optional unless getStoreInfo().requireBirthday is true (an absent field means false, and the same flag appears as connection.requireBirthday in the capabilities payload described in section 1), which makes them mandatory at registration. That flag is enforced on vc_* sales-channel registration only. A storefront connected by plain storeId has no channel to read it from, so it is not enforced there, the same reach limitation requireEmailVerification has, and it never applies to customers who already registered. It also gates the password register route alone: OAuth sign-in and guest checkout create the customer elsewhere and never consult the flag, so treat both fields as optional in every flow but that one form. February 29 is valid and is celebrated on February 28 in non-leap years.

Returns and exchanges (SDK 3.18.0 and newer)

A signed-in customer asks to send items of a shipped order back; the merchant approves or declines under the store's return rules and closes it with a refund to the original payment method or an exchange (goods back to stock, no money moved). Both modes, a customer token (or proxyMode) is required.

const eligibility = await client.getReturnEligibility(order.id);
if (eligibility.eligible) {
  const created = await client.requestReturn({
    orderId: order.id,
    items: [{ lineId: eligibility.lines[0].lineId, quantity: 1, reasonCode: eligibility.reasons[0].code }],
  });
  // created.status: 'REQUESTED' (or 'APPROVED' when the store approves automatically)
}
  • eligible: false carries a reason: RETURNS_DISABLED, NOT_SHIPPED or WINDOW_EXPIRED. Per line, returnableQuantity and a block reason (GIFT_CARD, DIGITAL, BUNDLE, FINAL_SALE, ALREADY_RETURNED).
  • The server checks the rules again on every requestReturn(); a refusal is a 400 whose error.code is the rule (also QUANTITY_EXCEEDS_RETURNABLE). A clientRequestId is filled for you; the same one again replays the first return.
  • status is REQUESTED, APPROVED, DECLINED (show declineMessage), RECEIVED, COMPLETED or CANCELLED. cancelMyReturn() works only while REQUESTED (409 after).
  • An order or return that is not the customer's answers 404; in sales-channel mode only orders placed through that channel are reachable. A shopper never sees the merchant's notes, the internal decline reason or the refund mechanics.
  • Not supported: guest returns, automatic return labels, store credit, an automatic replacement order for an exchange.

Walkthrough, REST routes and the starter's version of it: Optional features, Returns.

Loyalty and memberships

MethodPurposeModes
getLoyaltyStatus(): Promise<LoyaltyStatus>Points (incl. pendingPoints), tier, progressChannel, Store
enrollInLoyalty(): Promise<LoyaltyStatus>Opt inChannel, Store
getAvailableRewards(): Promise<LoyaltyReward[]>What the customer can redeemChannel, Store
getRecommendedReward(): Promise<LoyaltyRewardRecommendation>The single best next rewardChannel, Store
redeemLoyaltyReward(rewardId): Promise<RedeemRewardResult>Redeem oneChannel, Store
reportSocialShare(platform?: string): Promise<{ awarded: boolean; points: number }>Award share pointsChannel, Store
getReferralInfo(code: string): Promise<ReferralInfo>Resolve a referral codeChannel, Store
getMembershipPlans(): Promise<LoyaltyMembershipPlan[]>Paid membership tiersChannel, Store
getMySavedPaymentMethods(): Promise<StorefrontSavedPaymentMethod[]>Vaulted cards to pick a savedPaymentTokenId from (vaulted via saveCard: true at checkout)Channel, Store
subscribeToMembership(params: { planId, savedPaymentTokenId }): Promise<PaidMembershipInfo>SubscribeChannel, Store
cancelMembership(): Promise<PaidMembershipInfo>CancelChannel, Store
getLoyaltyWidgetSession(): Promise<{ sessionId, expiresAt, storeId, embedUrl }>Session for the embeddable widgetChannel, Store

15. Site chrome from merchant content

client.content is a typed namespace, not a flat method. Every content type exposes the same shape:

client.content.faq.get(key = 'main', locale?)   // one PUBLISHED entry, or null
client.content.faq.list(locale?)                // all PUBLISHED entries (Channel / Store only)
client.content.faq.create(input, storeId)       // admin only

Types: faq, footer, header, announcement, richText, page. page adds getBySlug(slug, locale?) for a catch-all [slug] route.

CallPurposeModes
content.header.get('main', locale?)Logo, nav items, CTAChannel, Store
content.footer.get('main', locale?)Columns, social links, copyrightChannel, Store
content.announcement.list(locale?)Announcement bar entriesChannel, Store
content.faq.get('main', locale?)FAQ accordionChannel, Store
content.page.getBySlug(slug, locale?)A static page (About, Terms, Privacy…)Channel, Store
content.richText.get(key?, locale?)A free-form HTML blockChannel, Store
content.findById(id, storeId)One row by admin id; throws on 404Admin only
content.listAdmin({ storeId, type?, status? })Cross-type admin listing incl. draftsAdmin only
content.<type>.create(input, storeId)New row in DRAFTAdmin only
content.update(id, input, storeId)Replace dataAdmin only
content.publish(id, storeId) / content.unpublish(id, storeId)DRAFT ↔ PUBLISHEDAdmin only
content.remove(id, storeId)Hard deleteAdmin only

get() and getBySlug() return null when the merchant has not seeded anything. Render a hard-coded fallback so the page never crashes. And RICH_TEXT, PAGE and FAQ answers contain raw HTML that the server does not sanitize, because some merchants embed iframes on purpose. Sanitize before dangerouslySetInnerHTML, every time.

Every Admin-only call takes an explicit storeId as its last argument. Admin mode has no ambient store (storeId is only set in Store mode) and the admin content routes are store-scoped, so the SDK sends it as a query param, never a body field. Omitting it is rejected fail-closed by the store scope guard with 403 STORE_SCOPE_REQUIRED before the handler ever 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.

Calling a write method from Channel or Store mode throws client.content.<action>() requires admin mode (apiKey). The reverse holds too: content.<type>.get() and content.<type>.list() are public-read APIs and both throw from admin mode, because the admin API has no by-key read and its list route is store-scoped. Use listAdmin({ storeId, type }) or findById(id, storeId) instead.

16. Blog and SEO

client.blog is the second namespace:

CallPurposeModes
blog.getPosts(params?: BlogPostListParams, storeId?)Posts; filters category, tag, page, limit. Admin mode lists drafts too and requires storeId; the other modes ignore itChannel, Store, Admin
blog.getPost(slug)One PUBLISHED post by slug, or null. Throws in Admin modeChannel, Store
blog.findById(id, storeId)One post by admin id, drafts included, or null on 404Admin only
blog.create(input, storeId)New post in DRAFTAdmin only
blog.update(id, input, storeId)EditAdmin only
blog.publish(id, storeId) / blog.unpublish(id, storeId)Status transitionsAdmin only
blog.duplicate(id, storeId)Copy as a DRAFT titled " (Copy)" with a new slug; never published or datedAdmin only
blog.remove(id, storeId)Hard deleteAdmin only

Post bodies are raw HTML, so sanitize before rendering.

The admin blog routes are store-scoped and looked up by id, not by slug. storeId is required on every Admin call and travels as a query param, with the same 403 STORE_SCOPE_REQUIRED fail-closed rejection as Content when it is missing or names a store the key is not bound to. The key needs blog:read for the reads and blog:write for the writes. getPost(slug) throws in Admin mode rather than issuing a request that could only 404, so reach for findById(id, storeId), or find the post first with getPosts({}, storeId). Note the mismatch between the two findById methods: blog.findById resolves to null on 404, while content.findById throws.

Sitemap, redirects and structured data

Method / exportPurposeModes
getSitemapProducts(limit = 5000)Lightweight { id, slug, updatedAt, localeSlugs } rowsChannel only
resolveSlugRedirect(entityType: 'product' | 'blog', slug): Promise<{ currentSlug: string } | null>Resolve a renamed slug to its current oneChannel only
getProductSitemapEntries(client, opts): Promise<SitemapEntry[]>Product URLs, via the dedicated endpointexport
getCategorySitemapEntries(client, opts): Promise<SitemapEntry[]>Category URLsexport
getBlogSitemapEntries(client, opts): Promise<SitemapEntry[]>Blog URLsexport
buildProductJsonLd(product, opts)Product schema, PDP onlyexport
buildCollectionPageJsonLd(category, opts)CollectionPage schema, category pagesexport
buildArticleJsonLd(post, opts)Article schema, blog postsexport
buildOrganizationJsonLd(store, opts)Organization schema, site-wideexport
buildWebsiteJsonLd(store, opts)WebSite schema, with optional search actionexport
buildBreadcrumbJsonLd(items)BreadcrumbList schemaexport
buildProductFaqJsonLd(product)FAQPage schema from product.faq, or nullexport
jsonLdScriptProps(data)Props for the <script type="application/ld+json"> tagexport

Build your product sitemap from getProductSitemapEntries(), not getProducts(). The listing API clamps limit to 100, so a getProducts({ limit: 1000 }) sitemap silently truncates. The helper uses a dedicated endpoint with a 5000 cap.

Call resolveSlugRedirect() in the not-found path of /products/[slug] and /blog/[slug] and issue a permanent redirect. The platform records every slug rename; without this, each dashboard slug edit permanently 404s the old URL.

Never emit buildProductJsonLd on a listing page.

17. Categories, brands and tags

MethodPurposeModes
getCategories(options?: { locale? }): Promise<{ categories: CategoryNode[] }>The category tree for navigationChannel only
getCategoryBySlug(slug, options?: { locale? }): Promise<CategoryDetail>Category landing payloadChannel only
getBrands(options?: { locale? }): Promise<{ brands: Array<{ id, name }> }>Brand list for filtersChannel only
getTags(options?: { locale? }): Promise<{ tags: Array<{ id, name, image }> }>Tag list for filters and tag tilesChannel only

A tag carries an optional picture, like a category: tag.image is the image URL, or null when the merchant set none, so render the tile only when it is present ({tag.image && <img src={tag.image} alt={tag.name} />}). Merchants set it in the dashboard or through createTag({ name, image }) / updateTag(tagId, { image }) (image: null removes it).

Category pages rank for broad research-intent queries that individual product pages never capture. Feed the grid with getProducts({ categories: [category.id] }).

18. Regions, currency and tax

MethodPurposeModes
getStoreRegions(): Promise<{ data: PublicRegion[] }>Regions the storefront may offerChannel, Store
getStoreRegion(regionId): Promise<PublicRegionDetail>One region's detailChannel, Store
getAutoRegion(country?): Promise<AutoRegionResponse>Best region for the visitorChannel, Store
detectRegion(country, regions): Region | PublicRegion | nullLocal matcher over an already-fetched list. SynchronousLocal
estimateTax(params: { country?, subtotal: number, regionId? }): Promise<TaxEstimateResponse>Non-binding tax preview for PDP / cart (regionId optional: follows that region's tax-inclusive setting)Channel, Store
getStoreTaxClasses(): Promise<{ data: PublicTaxClass[] }>Public tax classesChannel, Store

A store has one base currency. Passing regionId to a product read adds displayPrice / displaySalePrice / displayCurrency (the read never charges; in a region charged in its own currency the price is converted, then rounded up to the merchant's step, and checkout charges that same price). Passing regionId to createCheckout is different: a presentment-enabled region charges in the region currency and the checkout carries a presentment overlay. See Regions and FX analytics.

19. Contact forms

client.contactForms is the third namespace.

CallPurposeModes
contactForms.list(): Promise<ContactFormSummary[]>Active forms (main, newsletter, …)Channel, Store
contactForms.get(formKey = 'main', locale?): Promise<ContactFormPublic>Full schema with localized labels and validationChannel, Store
createInquiry(input: CreateInquiryInput): Promise<CreateInquiryResponse>SubmitChannel, Store

Rate limited to 3 submissions per 60 s per IP. Include an empty honeypot field on the form and never send it, because auto-filling bots are rejected.

A form keyed newsletter is still an inquiry: it files a message and never touches marketing consent. For a mailing list use marketing.subscribe() below.

20. Marketing signup

client.marketing is the fourth namespace (SDK >= 1.60).

CallPurposeModes
marketing.subscribe(input: SubscribeMarketingInput): Promise<SubscribeMarketingResponse>Start a confirmed opt-inChannel, Store
marketing.getBenefit(locale?): Promise<PublicNewsletterBenefitOffer | null>Read the welcome offer to advertise (SDK >= 2.7)Channel, Store

It does not subscribe anyone. The contact is created and mailed a confirmation link; the address is unmailable, and invisible to every campaign audience, until that link is clicked. Show "Check your email to confirm" on success, never "You're subscribed".

{ ok: true } is returned identically for a new address, an already-confirmed one, one inside its 24-hour resend cooldown, and one suppressed after a hard bounce. The form must not become a way to test who shops here. There is nothing to branch on.

Rate limited to 3 requests per 60 s per IP, plus one confirmation email per address per store per 24 hours. Same honeypot contract as the contact forms.

Pass locale on a multi-language storefront or the confirmation email falls back to the store's language; he and en are written, everything else gets English.

The welcome offer. marketing.getBenefit(locale) returns what the merchant promises anyone who confirms: the discount, how long the coupon lasts, any minimum order, whether it is first-order only, and the merchant's own headline and terms. It returns null when the store offers nothing, so handle that and render the plain form.

⛔ Never render a coupon code on the signup screen. No coupon exists when subscribe() resolves. It is minted when the recipient clicks the confirmation link and is mailed to them there, which is what stops a forwarded link handing the discount to someone who never asked. The API-served confirmation page already shows the code, its expiry and its terms.

⛔ It takes no email address and never will. A per-address answer would be an unauthenticated way to test who already subscribed, so there is no eligibility check to call. The offer is one per address per store, forever; say so in the terms rather than detecting it.

21. Back-in-stock alerts

client.stockAlerts is the fifth namespace (SDK >= 1.61).

CallPurposeModes
stockAlerts.subscribe(input: CreateStockAlertInput): Promise<StockAlertResponse>Ask to be told once when it's backChannel, Store

It is not a subscription. One email, about one item, with a link that stops it. No customer account is created and no marketing consent is granted, so a shopper who has unsubscribed from marketing can still use it. Never gate the button on consent, and never label it "Subscribe".

Four conditions, all of them:

store.stockAlertsEnabled !== false &&
  inv?.trackingMode === 'TRACKED' &&
  !inv.canPurchase &&
  (inv.backorderMode ?? 'NONE') === 'NONE';

Requests failing any of them (a storefront whose merchant switched the feature off, an in-stock item, a backorderable one, an untracked one, an unknown product id) are silently ignored, so a button in the wrong place looks like it worked and does nothing. backorderMode arrived on InventoryInfo in SDK 1.61; treat undefined as 'NONE'.

Pass variantId on any product with variants, or the alert waits on the product as a whole and a shopper who wanted the medium is mailed when the small returns.

{ ok: true } is returned identically for a new request, a duplicate, an unknown product and a suppressed address. The endpoint must not become a way to read a store's stock levels. There is nothing to branch on.

Sending is not immediate. Availability is total - reserved - unavailable, so an expiring cart briefly lifts a sold-out item above zero; the alert waits for stock to hold, then goes out in waves sized to the units that came back, oldest request first. A shopper can therefore sit through a restock without hearing, so do not promise "you'll be the first to know". The merchant sets the ratio (and can set it to "everyone at once") under Channel settings → Inventory, so do not hard-code an assumption about it either.

Rate limited to 5 requests per 60 s per IP, with at most 25 open alerts per address per store and a 90-day life on an unfired one. Same honeypot contract as the contact forms.

Pass locale on a multi-language storefront or the alert falls back to the store's language; he and en are written, everything else gets English.

22. Wishlist (saved items)

Available from SDK 3.16.0. client.wishlist is the sixth namespace and does not exist in earlier versions. Do not call addToWishlist() or getWishlist(): neither is a method.

A guest's list lives in the visitor's own browser (localStorage, one list per store, at most 100 items). A signed-in customer's list lives on the server (one list per customer, at most 200 items). syncOnLogin() moves the first into the second. The SDK does not decide which applies: your code picks local for a guest and the server methods for a signed-in customer.

CallPurposeModes
wishlist.keys(): Promise<WishlistKey[]>The whole list, newest first, in one readChannel, Store (signed in)
wishlist.list(params?: { page?, limit? }): Promise<PaginatedResponse<WishlistEntry>>One page (limit at most 100)Channel, Store (signed in)
wishlist.add(key: WishlistKey): Promise<{ changed: boolean; count: number }>Save one. Idempotent: an item already saved answers changed: false. At 200 items: HTTP 400 code: 'WISHLIST_FULL'Channel, Store (signed in)
wishlist.remove(key: WishlistKey): Promise<{ changed: boolean; count: number }>Remove one. Omitting variantId removes the product saved as a wholeChannel, Store (signed in)
wishlist.merge(items: WishlistKey[]): Promise<{ merged: number; skipped: number; count: number }>Fold a browser list into the account (at most 100 items). Items that cannot be saved are counted in skippedChannel, Store (signed in)
wishlist.local.keys() / add(key) / remove(key) / has(key) / clear()The guest list in the browser. Synchronous, never throws, reads as empty on a server render or with storage blockedLocal
wishlist.syncOnLogin(): Promise<{ merged: number; keys: WishlistKey[] }>Move the guest list into the account and return the account's list. Clears the browser list only after the server accepted itChannel, Store (signed in)
wishlist.resolve(keys, options?: { regionId?, locale? }): Promise<Product[]>Saved ids to live products (current price, stock, translation), in the order saved; drops what the catalog no longer returnsChannel, Store

WishlistKey is { productId: string; variantId?: string | null }; a WishlistEntry adds id and an ISO createdAt. The server methods throw 401 without a signed-in customer (a customer token, or the login cookie in proxyMode) and 400 in apiKey mode.

  • A server error is not an empty list. keys(), list() and syncOnLogin() throw on failure; render an error with a retry.
  • Only currently visible items are returned and counted. keys(), list(), count and total leave out a saved product the merchant has since unpublished or hidden. It stays saved and returns when the product is shown again.
  • syncOnLogin() after every sign-in path and on page load when a signed-in shopper still has a browser list (an OAuth redirect, another tab).
  • Sign out does not clear the browser list (onLogout() leaves wishlist.local alone).
  • Tracking: trackMarketingEvent('add_to_wishlist', ...) sends AddToWishlist to Meta and TikTok.

What the merchant sees: a Saved by column in Products, a Saved items column and card in Customers, counting signed-in customers only. A guest's saves never reach Brainerce.

What it does not do: no share link, no several lists per customer, no price-drop or back-in-stock emails for saved items, no wishlist webhook events, no merchant setting to turn it off, no /v1 API-key routes.

Walkthrough, REST routes and the starter's version of it: Optional features, Wishlist.

23. Store events (SDK 3.15.0 and newer)

A one-off event with a seat quota, where a guest registers without an account. Available in sales-channel and storefront modes. The methods say "store event" because "event" already means analytics and webhooks. An event is not a product, a cart or an order: a paid registration never appears under the merchant's orders.

MethodPurpose
getStoreEvents(options?: { locale? }): Promise<{ data: StoreEvent[] }>Published events, soonest first, at most 50. { data: [] } when there are none (or the merchant has the module off)
getStoreEvent(idOrSlug, options?: { locale? }): Promise<StoreEvent>One event by id or slug
getStoreEventAvailability(idOrSlug): Promise<StoreEventAvailability>The live state (open, full, closed, not_open_yet, past, cancelled) and seatsRemaining. Only open registers
registerForStoreEvent(idOrSlug, input): Promise<StoreEventRegistrationResult>Register seats seats. Free: CONFIRMED. Paid: HELD with a payment to complete. Rate limit 5 a minute
redirectToStoreEventPayment(registration): voidBrowser only: send the payer to the provider's own payment page (redirect providers such as Stripe and PayPal only; throws when the payment has no redirectUrl)
getStoreEventRegistrationStatus(registrationId)Poll after the payer returns until the status is final (CONFIRMED, EXPIRED, NEEDS_REFUND, CANCELLED). 30 a minute
getStoreEventRegistration(token)The guest's own registration, from the token in their confirmation email
requestStoreEventRegistrationLink({ email, registrationNumber })Email the guest a fresh link. Always answers { accepted: true }, so it never reveals whether a registration exists
  • Never confirm a paid registration from the browser. It becomes CONFIRMED only when the server has verified the payment.
  • Render startsAt and endsAt in event.timezone, not the visitor's zone.
  • The meeting link of an online event is not on the event. It reaches registered guests in the confirmation email.
  • A double submit is safe: the SDK sends a clientRequestId per call, and a paid event requires one on the wire.

What it does not do: no waiting list, no QR codes or PDF tickets, no ticket types, no recurring events, no reminders, no coupon or gift card on a seat, and registering is not consent to marketing email. Events are in a limited rollout for merchants, so a store may return an empty list.

Walkthrough, error codes and types: Optional features, Events.


Part 2: Admin reference (apiKey)

Everything below requires an API key and must run server-side only. A salesChannelId or storeId client throws on all of it.

Products, variants and media

MethodPurpose
createProduct(data: CreateProductDto): Promise<Product>Create one product
updateProduct(productId, data: UpdateProductDto): Promise<Product>Update it
deleteProduct(productId, options?: { platforms?: string[] }): Promise<DeleteProductResponse>Delete. platforms also removes the listing from connected apps (queued). A kit component is refused with 409
convertToVariable(productId): Promise<Product>Simple → variable
convertToSimple(productId): Promise<Product>Variable → simple
bulkCreateProducts(data: BulkCreateProductsDto): Promise<BulkCreateProductsJob>Queue a catalog import
getBulkCreateProductsStatus(jobId): Promise<BulkCreateProductsStatus>Poll the job
getBulkCreateProductsImportStatus(importId): Promise<BulkCreateProductsStatus>Poll by import id
getBulkCreateProductsErrors(jobId, options?: { page?, limit? }): Promise<PaginatedResponse<BulkCreateProductsError>>Per-row failures
createVariant(productId, data: CreateVariantDto): Promise<ProductVariant>Add a variant
updateVariant(productId, variantId, data: UpdateVariantDto): Promise<ProductVariant>Edit a variant
deleteVariant(productId, variantId): Promise<void>Remove a variant
bulkSaveVariants(productId, data: BulkSaveVariantsDto): Promise<BulkSaveVariantsResponse>Upsert variants (omitted ones are kept)
getKitComponents(productId): Promise<KitDetail>A kit's contents, resolved price, and how many can be sold
setKitComponents(productId, data: { components, pricingMode?, discountValue? }): Promise<KitDetail>Replace a kit's contents and how it is priced
getVariantInventory(productId, variantId): Promise<VariantInventoryResponse>Stock for one variant
updateVariantInventory(productId, variantId, data: UpdateVariantInventoryDto): Promise<VariantInventoryResponse>Set it
updateInventory(productId, data: UpdateInventoryDto): Promise<ProductInventoryResponse>Set product-level stock to an absolute level; data.variantId sets one variant instead (a KIT is refused: set stock on its components)
uploadMedia(input: File | Blob | { sourceUrl: string }): Promise<MediaAsset>Upload, or ingest from a URL
listMedia(params?: ListMediaParams): Promise<PaginatedResponse<MediaAsset>>Browse the media library
getMedia(id): Promise<MediaAsset>One asset
updateMediaAsset(id, data: UpdateMediaAssetInput): Promise<MediaAsset>Rename, alt text (alt, per-language altTranslations)
deleteMedia(id): Promise<{ success: boolean }>Delete

Ingest images before you attach them. uploadMedia({ sourceUrl }) returns a real asset with a real storage key. An external URL is not a key, and a stored key is never URL-shaped.

createProduct({ type: 'KIT' }) gives you an EMPTY kit, and an empty kit cannot be bought. Creating the product is only half the job: a kit is defined by its component list, so until you call setKitComponents(productId, { components }) the kit has no price and addToCart rejects it as unavailable. Create, then set components, in that order. getKitComponents is safe to call on any product: a non-KIT returns an empty, unsellable shape instead of throwing.

Pricing a kit. kitPricingMode is settable on createProduct / updateProduct (alongside kitDiscountValue) and on setKitComponents (as pricingMode / discountValue):

ModeWhat the kit charges
FIXED (default)The kit's own basePrice / salePrice, unchanged when components reprice.
SUMExactly what the components cost, recomputed on every read. A component going on sale lowers the kit price on its own.
SUM_MINUS_PERCENTThat sum, less kitDiscountValue percent (0-100).

Outside FIXED, the stored basePrice is a placeholder and the kit's own salePrice is ignored. Omitting pricingMode on setKitComponents leaves the current mode alone. Rejected on write: a product that is not a KIT, a component from another store, a component that is itself a KIT, a VARIABLE component with no variant pinned, a variant that does not belong to its product, and the same slot listed twice.

Prices in CreateProductDto are numbers: send basePrice: 49.99 and salePrice: 39.99, not strings. Only the Product you get back carries its prices as strings ("49.99"), so convert before doing arithmetic on a response. There is no compareAtPrice on this API.

Rules that answer 400 on createProduct, updateProduct, createVariant, updateVariant and bulkSaveVariants (the single-product routes; the bulk import bulkCreateProducts keeps its own row rules):

  • A SKU is unique across the whole store, products and variants together. The comparison ignores case and surrounding spaces (MUG-1 and mug-1 are the same SKU). The error names the SKU and the product that owns it. Only a SKU the call changes is checked, so a product that already holds a duplicate stays editable. Two variants of one product also cannot repeat the same option combination ({ Size: 'S' } twice).
  • A price with more decimals than the store currency allows is refused, never rounded. basePrice, salePrice and costPrice on a product, and price, salePrice and costPrice on a variant (including variants[] inside createProduct): 2 decimals for USD, EUR and ILS, none for JPY and KRW. The error names the field and the currency (basePrice (10.505) has more decimal places than USD allows). Round on your side before you send.

Orders and shipments

MethodPurpose
getOrders(params?: OrderQueryParams): Promise<PaginatedResponse<Order>>List orders. Filters combine: status, financialStatus, fulfillment (unfulfilled is orders still to pack), salesChannelId (vc_...), paymentMethod, isTestOrder, tag / excludeTag (internal order tags)
getOrder(orderId): Promise<Order>One order
updateOrder(orderId, data: UpdateOrderDto): Promise<Order>Update an order
previewOrderItemsEdit(orderId, input: EditOrderItemsInput): Promise<OrderItemsEditResult>Admin mode, scope orders:read. What an items edit would do (new lines, totals, balanceDue, warnings, stock); writes nothing. An order that cannot be edited answers editable: false, a reason and (for most cases) a stable reasonCode. See Edit the items of an order
editOrderItems(orderId, input: EditOrderItemsInput, options?: IdempotentRequestOptions): Promise<OrderItemsEditResult>Admin mode, scope orders:write. Add a product, remove a line or change a quantity on an order that has not shipped. No prices in the request. Never charges or refunds: a paid order that grew becomes partially_paid with a positive balanceDue, one that shrank stays paid with a negative balanceDue. expectedUpdatedAt gives a 409 if the order changed
getOrderShippingRates(orderId): Promise<…>Live carrier rates: id, name, price (string), currency, estimatedDays, carrier, service, quoted? (false for a carrier with no rate API: price is a placeholder)
createShippingLabel(orderId, data): Promise<…>Buy a label or dispatch an on-demand courier. { rateId, parcel?, labelFormat?: 'PDF' | 'PNG' | 'ZPL' | 'EPL', customsContentsType? } → { shipmentId, labelUrl?, trackingNumber, carrier, labelFormat? } — labelUrl is absent for a carrier with no printable label
getOrderShipments(orderId): Promise<…>Shipments with carrier, status, tracking number and URL, label URL, delivery dates, rate, and the 200 most recent tracking events
createOrder(data: CreateOrderDto): Promise<Order>Bypasses payment. Sandbox and COD only. The order is always created PENDING: a status in the body is accepted (any of the 12 statuses) but ignored, so change it afterwards with updateOrder(orderId, { status })

customsContentsType is cross-border only; the declaration itself is built from the order's line items.

Customers

MethodPurpose
createCustomer(data: CreateCustomerDto): Promise<Customer>Create
getCustomer(customerId): Promise<Customer>Read
getCustomerByEmail(email): Promise<Customer | null>Look up by email
updateCustomer(customerId, data: UpdateCustomerDto): Promise<Customer>Update
getCustomerOrders(customerId, params?: { page?, limit? }): Promise<PaginatedResponse<Order>>Their order history
getCustomerAddresses(customerId): Promise<CustomerAddress[]>Their addresses
addCustomerAddress(customerId, address: CreateAddressDto): Promise<CustomerAddress>Add one
updateCustomerAddress(customerId, addressId, address: UpdateAddressDto): Promise<CustomerAddress>Edit one
deleteCustomerAddress(customerId, addressId): Promise<void>Remove one
listSavedPaymentMethods(customerId): Promise<SavedPaymentMethodSummary[]>Vaulted cards
removeSavedPaymentMethod(customerId, paymentMethodId): Promise<{ success: true }>Delete a vaulted card

Both saved-payment-method methods have a deprecated (storeId, customerId, …) overload. Use the two-argument and three-argument forms shown above.

Field rules for createCustomer and updateCustomer. A violation is a 400 whose message names the field, and nothing is saved.

  • phone: an optional leading +, then digits, spaces, dashes, dots and parentheses, with at least 5 digits and at most 50 characters. +972 50-123-4567, (555) 123-4567 and 050-1234567 pass; letters are refused, so 'n/a' fails. An empty string is accepted. Nothing is normalised. The rule applies to these two methods only, not to setCheckoutCustomer, the shopper's own profile (updateMyProfile) or file imports.
  • firstName and lastName: at most 100 characters each. role: at most 50. email: a valid address of at most 320 characters. Each entry of tags: at most 100.
  • A second customer in the store with the same email is a 409.

Gift cards (administration)

⛔ API key only, and never from a storefront. gift_cards:issue mints stored value and gift_cards:adjust rewrites a balance. An API key is a server credential; putting one where a browser can reach it hands a stranger the ability to create money. A storefront's whole gift-card job is redemption — see Gift cards (a tender, not a discount).

These are for back-office work: a POS issuing at a till, a campaign minting a batch, an ERP reading balances.

MethodScopePurpose
listGiftCards(params?)gift_cards:readList. search matches the LAST FOUR of a code or part of a recipient email — a full code cannot be searched, only an HMAC is stored
getGiftCardLiability()gift_cards:readThe month-end figure, per currency
getGiftCard(id)gift_cards:readOne card with its full ledger
issueGiftCard(data)gift_cards:issueMint a card. Returns the code once
reissueGiftCard(id, note)gift_cards:issueNew code, whole balance moved, old card revoked
adjustGiftCardBalance(id, delta, note)gift_cards:adjustSigned decimal: "25.00" adds, "-25.00" removes
setGiftCardStatus(id, status)gift_cards:writeACTIVE / DISABLED / REVOKED
bulkSetGiftCardStatus(giftCardIds, status, options?)gift_cards:writeACTIVE or DISABLED only

The code is returned exactly once

issueGiftCard and reissueGiftCard put plaintextCode in their response and nowhere else, ever. Only an HMAC of it is stored, so no later call, no dashboard screen and no database query can produce it again.

An integration that logs the response and moves on has destroyed a card. Persist it or deliver it before you discard the response.

const card = await client.issueGiftCard({
  amount: '200.00',
  note: 'Compensation for order #1042', // required — see below
  recipientEmail: '[email protected]',
  expiresAt: '2027-01-01T00:00:00.000Z', // optional; omit for a card that never expires
});
await deliver(card.plaintextCode); // your only chance

There is no currency field: a card is minted in the store's own currency, and it can only ever pay in that currency — there is no conversion anywhere.

expiresAt is optional, and write-once

Omit it and the card never expires, which is the default. Pass it and it must be ISO 8601 and in the future: a past or present date is refused with a 400.

Nothing changes an expiry once the card is minted. There is no route, no field on any other call, and reissueGiftCard carries the original date forward rather than starting a fresh one, so a card can never be given more time. Set the wrong date and the only route is to adjust that card to zero, disable it, and issue a new one.

There is no expiry job. A lapsed card is refused at read time, in two places: checkGiftCardBalance reports it unusable, and the moment a checkout tries to hold value on it the hold is refused. Its balance is never zeroed: it is reported by getGiftCardLiability() as expired-but-not-written-off, on its own line rather than dropped from what the store owes.

A card is refused before its stated date if it would lapse mid-checkout. A checkout lives 30 minutes, and a card whose expiresAt falls inside that window is refused when it is applied, with the same generic refusal as everything else. Do not promise a shopper the full day printed on their card.

Re-issue is not a resend

It mints a new code, moves the whole balance across, and revokes the old card. The old code stops working the moment the call returns, so a customer holding a printed card loses it. Use it when a code was lost, not to re-send one.

It is refused while a checkout holds value on the card, and the original expiry carries forward — this cannot be used to restart an expiry clock that may carry statutory notice duties.

A note is mandatory, and there is no delete

issueGiftCard, reissueGiftCard and adjustGiftCardBalance all require a note. It is written to an append-only ledger permanently — it is the row a finance review reads a year from now, and moving stored value with no stated reason is not auditable.

Nothing deletes a gift card. Not one route, not in bulk, not ever: the ledger is append-only and a card may carry a statutory retention life. setGiftCardStatus with DISABLED is the reversible substitute, and REVOKED is refused in bulk because it would strand balances with nowhere to go.

Ask for the least scope you need

The four scopes are separable on purpose. A reporting integration needs gift_cards:read and nothing else; service-recovery tooling needs issue without adjust. Neither is implied by read.

Worth knowing if you are porting from Shopify: they require you to contact their support to be granted the equivalent scope, and a human approves it. Brainerce grants it self-serve, which puts the whole safety margin on you asking for less.

Coupons

MethodPurpose
getCoupons(params?: CouponQueryParams): Promise<PaginatedResponse<Coupon>>List
getCoupon(couponId): Promise<Coupon>Read
createCoupon(data: CreateCouponDto): Promise<CouponCreateResponse>Create. validationWarnings on the response type is never set today. Publish the coupon to a channel with publishCouponToSalesChannel before shoppers can use it. Refused with a 400: a value of 0 or with more than 2 decimals, a PERCENTAGE value above 100, a usageLimit or usageLimitPerCustomer of 0 (omit it for unlimited), a usageLimitPerCustomer above usageLimit, a minimumOrderAmount with more than 2 decimals, a code containing a space (surrounding spaces are trimmed). There is no free-shipping coupon type: use a SHIPPING_DISCOUNT rule
updateCoupon(couponId, data: UpdateCouponDto): Promise<Coupon>Update. Same rules for the fields you send; null removes a usage limit, 0 is refused, and a per-customer limit is checked against the stored total when you send only that field
deleteCoupon(couponId): Promise<void>Delete
syncCoupon(couponId): Promise<SyncJob>Deprecated no-op. Nothing is queued: the answer has success: false and an empty jobIds. Use publishCouponToSalesChannel
publishCoupon(couponId, platforms: ConnectorPlatform[]): Promise<SyncJob>Deprecated no-op. No job is queued; platforms is required but ignored. Use publishCouponToSalesChannel
getCouponPlatformCapabilities(): Promise<Record<string, PlatformCouponCapabilities>>Deprecated. Always {}: coupons are not synced to other platforms

See Region coupons for multi-currency rules.

Newsletter welcome offer (client.newsletterBenefit)

Admin mode only, on the coupons:read (read) and coupons:write (write) scopes: the offer is a coupon feature and has no scope of its own. The shopper-facing half is client.marketing.getBenefit() (section 20).

CallRoutePurpose
newsletterBenefit.getSettings(): Promise<NewsletterBenefitSettings | null>GET /v1/newsletter-benefit/settingsThe saved offer. null means never configured; { enabled: false } means switched off. Both mean "offer nothing"
newsletterBenefit.updateSettings(input: UpdateNewsletterBenefitSettingsInput): Promise<NewsletterBenefitSettings>PUT /v1/newsletter-benefit/settingsCreate or replace the offer. A full replacement: an omitted field is cleared, not kept
newsletterBenefit.listGrants(params?: ListNewsletterBenefitGrantsParams): Promise<PaginatedResponse<NewsletterBenefitGrant>>GET /v1/newsletter-benefit/grantsIssued benefits, newest first
newsletterBenefit.resend(grantId: string): Promise<ResendNewsletterBenefitResult | null>POST /v1/newsletter-benefit/grants/{id}/resendRe-send the same code. Never mints a second coupon

UpdateNewsletterBenefitSettingsInput:

  • enabled, discountType ('PERCENTAGE' or 'FIXED_AMOUNT'), discountValue (1 to 100 for a percentage, an amount in the store currency for a fixed amount), combinesWithOther, validityDays (1 to 365, counted from issuance), eligibilityTtlHours (1 to 8760, counted from the signup) and firstOrderOnly are required.
  • minimumOrderAmount?, maximumDiscount? (percentage offers only; stored as null for a fixed amount), applicableProducts?, excludedProducts?, applicableCategories?, excludedCategories?, salesChannelIds? (empty means every enabled channel, expanded at issuance) and content? ({ en: { headline?, terms? }, he: {...} }, shown on the form and in the email).

ListNewsletterBenefitGrantsParams: page?, limit? (at most 100), status? ('PENDING' | 'ISSUING' | 'ISSUED' | 'EXPIRED' | 'FAILED'), from? and to? (ISO 8601, on the signup creation time). There is no email filter and the API refuses one; filter the page you get back.

There is no call that issues a benefit to an address. A benefit exists only because someone submitted the signup form and clicked the confirmation link. resend rejects a signup that has not been confirmed and one that lapsed before a coupon was minted. eligibilityTtlHours is the only deadline: the confirmation link never expires, so a click after the window still subscribes the address and earns no coupon. Saving never rewrites a promise already made: pending signups keep the terms they were shown and issued coupons are untouched. Switching enabled off stops new offers and leaves issued coupons working until they expire.

Reviews moderation

MethodPurpose
adminListProductReviews(productId, params?: { storeId?, page?, limit?, visibility?: 'visible' | 'hidden' | 'all' }): Promise<PaginatedResponse<ProductReviewAdmin>>List including hidden
hideProductReview(reviewId, storeId?): Promise<ProductReviewAdmin>Hide from the storefront
showProductReview(reviewId, storeId?): Promise<ProductReviewAdmin>Unhide
hideProductReviewImage(imageId, storeId?): Promise<ProductReviewImageAdmin>Hide ONE photo, keeping the review
showProductReviewImage(imageId, storeId?): Promise<ProductReviewImageAdmin>Show one photo, also approving a pending one

Reviews publish immediately; moderation is after the fact. Photos follow the same rule unless the store turns on reviewPhotosRequireApproval, and they are moderated per photo so a useful review with one bad picture loses the picture, not the review.

Taxonomy: categories, brands, tags, attributes

MethodPurpose
listCategories(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Category>>List
getCategory(categoryId): Promise<Category>Read
createCategory(data: CreateCategoryInput): Promise<Category>Create
updateCategory(categoryId, data: UpdateCategoryInput): Promise<Category>Update
deleteCategory(categoryId): Promise<void>Delete
listBrands(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Brand>>List
getBrand(brandId): Promise<Brand>Read
createBrand(data: CreateBrandInput): Promise<Brand>Create
updateBrand(brandId, data: UpdateBrandInput): Promise<Brand>Update
deleteBrand(brandId): Promise<void>Delete
listTags(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Tag>>List
getTag(tagId): Promise<Tag>Read
createTag(data: CreateTagInput): Promise<Tag>Create
updateTag(tagId, data: UpdateTagInput): Promise<Tag>Update
deleteTag(tagId): Promise<void>Delete
listAttributes(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Attribute>>List
getAttribute(attributeId): Promise<Attribute>Read
createAttribute(data: CreateAttributeInput): Promise<Attribute>Create
updateAttribute(attributeId, data: UpdateAttributeInput): Promise<Attribute>Update
deleteAttribute(attributeId): Promise<void>Delete
getAttributeOptions(attributeId): Promise<AttributeOption[]>List options
createAttributeOption(attributeId, data: CreateAttributeOptionInput): Promise<AttributeOption>Add an option

Duplicate names. A category name is unique per store, compared case-insensitively, and a different parent does not make it free. createCategory with a name the store already uses is a 400 whose code is CATEGORY_NAME_EXISTS (error.code on the thrown BrainerceError); switch on the code, not the English message. A duplicate brand or tag name is also a 400, but with an English message and no code.

Modifier groups

Every modifier method takes storeId as its first argument, and they are the only admin methods that do.

MethodPurpose
listModifierGroups(storeId, params?: ListModifierGroupsParams): Promise<PaginatedResponse<ModifierGroup>>List
getModifierGroup(storeId, groupId): Promise<ModifierGroup>One group with its modifiers
createModifierGroup(storeId, data: CreateModifierGroupInput): Promise<ModifierGroup>Create
updateModifierGroup(storeId, groupId, data: UpdateModifierGroupInput): Promise<ModifierGroup>Update
deleteModifierGroup(storeId, groupId): Promise<void>Delete
createModifier(storeId, groupId, data: CreateModifierInput): Promise<Modifier>Add a modifier
updateModifier(storeId, groupId, modifierId, data: UpdateModifierInput): Promise<Modifier>Update it
deleteModifier(storeId, groupId, modifierId): Promise<void>Delete it
toggleModifierAvailability(storeId, groupId, modifierId, available: boolean): Promise<Modifier>Toggle availability
attachModifierGroup(storeId, productId, data: AttachModifierGroupInput): Promise<ProductModifierGroupAttachment>Attach to a product
updateAttachment(storeId, productId, attachmentId, data: UpdateAttachmentInput): Promise<ProductModifierGroupAttachment>Edit the attachment
detachModifierGroup(storeId, productId, attachmentId): Promise<void>Detach

See Modifiers.

Shipping zones and rates

MethodPurpose
listShippingZones(params?: ShippingZoneQueryParams): Promise<PaginatedResponse<ShippingZone>>List
getShippingZone(zoneId): Promise<ShippingZone>Read
createShippingZone(data: CreateShippingZoneInput): Promise<ShippingZone>Create
updateShippingZone(zoneId, data: UpdateShippingZoneInput): Promise<ShippingZone>Update
deleteShippingZone(zoneId): Promise<void>Delete
getZoneShippingRates(zoneId): Promise<ShippingRateConfig[]>Rates in a zone
createZoneShippingRate(zoneId, data: CreateShippingRateInput): Promise<ShippingRateConfig>Add a rate
updateZoneShippingRate(zoneId, rateId, data: UpdateShippingRateInput): Promise<ShippingRateConfig>Update
deleteZoneShippingRate(zoneId, rateId): Promise<void>Delete

Rate rules for createZoneShippingRate and updateZoneShippingRate. The dashboard, /v1, the admin MCP tools and the AI assistant all go through the same check. A violation is a 400 whose message names the problem, and nothing is saved.

  • Every money value is a number of 0 or more with at most 2 decimals: 9.99 passes, -5 and 9.999 do not, and null or an omitted value means "not set". That covers rateConfig.amount (FLAT_RATE, LOCAL_PICKUP), rateConfig.minOrderAmount (FREE), rateConfig.baseRate, perKg, overflowPerKg and every weightTiers[].price (WEIGHT_BASED), every tiers[].minOrderAmount and tiers[].rate (PRICE_BASED), and the rate-level minOrderAmount, maxCost and every classCosts amount (flat, first and additional).
  • name is 1 to 100 characters.
  • minDeliveryDays and maxDeliveryDays are whole numbers of 0 or more, and minDeliveryDays cannot be higher than maxDeliveryDays. An update that sends only one end is checked against the end already saved.
  • classCalculationType is per_class, per_order or per_item.
  • A FREE rate with rateConfig.minOrderAmount omitted or 0 is free for every basket.
  • A rate saved before these rules that holds an out of range value is left as it is; only what an update sends is checked.

priority on a zone is a whole number of 0 or more and defaults to 0 (lower is evaluated first). listShippingZones returns zones ordered by priority, then creation time, then id, unless you pass sortBy, so zones at the default priority come back oldest first. That is list order only; which zone wins an address at checkout is decided separately.

Regions and regional prices

MethodPurpose
getRegions(): Promise<PaginatedResponse<Region>>List
getRegion(regionId): Promise<Region>Read
createRegion(data: CreateRegionDto): Promise<Region>Create
updateRegion(regionId, data: UpdateRegionDto): Promise<Region>Update
deleteRegion(regionId): Promise<void>Delete
setDefaultRegion(regionId): Promise<Region>Make it the default
addRegionCountries(regionId, countries: string[]): Promise<Region>Add countries
removeRegionCountry(regionId, countryCode): Promise<Region>Remove one
updateRegionPaymentProviders(regionId, providerIds: string[]): Promise<Region>Restrict providers per region
getRegionCompatibleProviders(regionId): Promise<Array<{ id, appId, name? }>>Which providers can serve it
getRegionPrices(regionId, params?: { productId?, page?, limit? }): Promise<PaginatedResponse<RegionPrice>>Manual overrides
upsertRegionPrices(regionId, entries: RegionPriceEntry[]): Promise<UpsertRegionPricesResult>Set overrides in bulk
deleteRegionPrice(regionId, priceId): Promise<void>Remove one override

Tax classes and rates

MethodPurpose
getTaxClasses(): Promise<{ data: TaxClass[] }>List
getTaxClass(id): Promise<TaxClass & { dependents: { productCount, variantCount, categoryCount, taxRateCount } }>Read with dependent counts
createTaxClass(data: CreateTaxClassDto): Promise<TaxClass>Create
updateTaxClass(id, data: UpdateTaxClassDto): Promise<TaxClass>Update
deleteTaxClass(id): Promise<void>Delete
setDefaultTaxClass(id): Promise<TaxClass>Make it the default
assignTaxClass(id, data: AssignTaxClassDto): Promise<{ updated: number }>Bulk-assign to products, variants and categories (at most 200 IDs per list)
mergeTaxClasses(id, targetId): Promise<void>Fold one class into another
getTaxRates(): Promise<TaxRate[]>List rates
getTaxRate(rateId): Promise<TaxRate>Read
createTaxRate(data: CreateTaxRateInput): Promise<TaxRate>Create
updateTaxRate(rateId, data: UpdateTaxRateInput): Promise<TaxRate>Update
deleteTaxRate(rateId): Promise<void>Delete
getTaxPresets(): Promise<Array<{ key; country; label; description; rateCount }>>List country presets (CA, IL, US)
applyTaxPreset(presetKey): Promise<{ preset; created; rates: TaxRate[] }>Write a country's whole rate table

applyTaxPreset('CA') writes Canada's ten rates (federal GST, five HST provinces, PST/RST/QST for BC/SK/MB/QC). applyTaxPreset('IL') writes one non-stackable VAT 18% row for Israel; it does not turn on the store's pricesIncludeTax setting, which most Israeli shops want, and it does not handle the Eilat exemption. applyTaxPreset('US') writes 46 rows: the state base sales tax for 45 states and DC, as of 2026-07-01. County, city and district taxes are not included, product exemptions are not modelled, and it covers every state, so keep only the states where the store is registered. AK, DE, MT, NH and OR get no row. All three throw 409 when the store already has a rate for that country.

See Tax classes.

Metafields

MethodPurpose
getMetafieldDefinitions(): Promise<MetafieldDefinition[]>List definitions
getMetafieldDefinition(definitionId): Promise<MetafieldDefinition>Read one
createMetafieldDefinition(data: CreateMetafieldDefinitionInput): Promise<MetafieldDefinition>Create
updateMetafieldDefinition(definitionId, data: UpdateMetafieldDefinitionInput): Promise<MetafieldDefinition>Update
deleteMetafieldDefinition(definitionId): Promise<void>Delete
setMetafieldPlatforms(definitionId, data: SetMetafieldPlatformsInput): Promise<MetafieldDefinition>Deprecated, record-only. The list is stored but nothing is pushed to any platform
getProductMetafields(productId): Promise<ProductMetafieldValue[]>Values on a product
setProductMetafield(productId, definitionId, data: UpsertProductMetafieldInput): Promise<ProductMetafieldValue>Set a value
deleteProductMetafield(productId, definitionId): Promise<void>Clear a value
getMetafieldConflicts(): Promise<MetafieldConflict[]>Deprecated. Always []: no conflict is ever created
resolveMetafieldConflict(conflictId, data: ResolveMetafieldConflictInput): Promise<MetafieldConflict>Deprecated. Always 404
ignoreMetafieldConflict(conflictId): Promise<MetafieldConflict>Deprecated. Always 404

Only definitions marked filterable on SELECT, MULTI_SELECT and BOOLEAN types surface in getMetafieldFilters().

Publishing to sales channels

MethodPurpose
publishProductToSalesChannel(productId, salesChannelId): Promise<{success, publish, connectionName}>Publish a product
unpublishProductFromSalesChannel(productId, salesChannelId): Promise<{ success: boolean }>Unpublish it
publishCustomerToSalesChannel(customerId, salesChannelId): Promise<{ success: boolean }>Publish a customer
unpublishCustomerFromSalesChannel(customerId, salesChannelId): Promise<{ success: boolean }>Unpublish
publishCouponToSalesChannel(couponId, salesChannelId): Promise<{ success: boolean }>Publish a coupon
unpublishCouponFromSalesChannel(couponId, salesChannelId): Promise<{ success: boolean }>Unpublish it
publishCategoryToVibeCodedSite(categoryId, vibeCodedConnectionId): Promise<{ success: boolean }>Publish a category
unpublishCategoryFromVibeCodedSite(categoryId, vibeCodedConnectionId): Promise<{ success: boolean }>Unpublish
publishTagToVibeCodedSite(tagId, vibeCodedConnectionId): Promise<{ success: boolean }>Publish a tag
unpublishTagFromVibeCodedSite(tagId, vibeCodedConnectionId): Promise<{ success: boolean }>Unpublish it
publishBrandToVibeCodedSite(brandId, vibeCodedConnectionId): Promise<{ success: boolean }>Publish a brand
unpublishBrandFromVibeCodedSite(brandId, vibeCodedConnectionId): Promise<{ success: boolean }>Unpublish

salesChannelId / vibeCodedConnectionId accept the channel's record id or its public vc_* connection id, not its name. That is the v1 API contract, and it is the narrower of the two channel-reference contracts on the platform: the Admin MCP channel tools (publish_product_to_channel(s), publish_coupon_to_channel(s), the overlay and channel CRUD tools, team channel scoping) and the dashboard routes behind them also accept the exact channel name, case-insensitively. A name that matches more than one channel on the store is rejected there rather than resolved to the first match, because channel names are not unique per store. An unresolved reference is an error naming the reference; it never silently narrows the call to fewer channels.

Reading back: products, categories, tags, brands, coupons and customers carry channelPublishes: Array<{ salesChannel: { id, name, connectionId } }>. vibeCodedPublishes (with a nested connection key) is a deprecated alias of the same array, so read channelPublishes.

Published is not always visible. In the dashboard a merchant can switch a product off for one channel (draft on that channel) while it stays published there. A channel's storefront reads, cart and checkout treat that product as not published (a product list omits it, a read by slug finds nothing, an add to cart answers 404), and the product itself and every other channel are untouched. The status is a dashboard control: channelPublishes does not carry it, and there is no SDK or /v1 call that sets it. If a product you published through the API does not show on a channel, check its status for that channel in the dashboard.

Connector sync (deprecated)

MethodPurpose
triggerSync(platform?: ConnectorPlatform): Promise<SyncJob>Deprecated. POST /v1/sync always answers 501 NOT_IMPLEMENTED
getSyncStatus(jobId): Promise<SyncJob>Deprecated. GET /v1/sync/{jobId} always answers 501

There is no generic sync to trigger, and Shopify and WooCommerce are not sync targets. To make a resource available on one of your storefronts, publish it to that sales channel with the per-resource methods (publishProductToSalesChannel, publishCouponToSalesChannel, publishCategoryToVibeCodedSite and the tag and brand equivalents above).

Email settings and templates

MethodPurpose
getEmailSettings(): Promise<EmailSettings>Per-event email settings
updateEmailSettings(data: UpdateEmailSettingsInput): Promise<EmailSettings>Update them
getEmailTemplates(): Promise<EmailTemplatesResponse>List templates
getEmailTemplate(templateId): Promise<EmailTemplate>Read one
createEmailTemplate(data: CreateEmailTemplateInput): Promise<EmailTemplate>Create
updateEmailTemplate(templateId, data: UpdateEmailTemplateInput): Promise<EmailTemplate>Update
deleteEmailTemplate(templateId): Promise<void>Delete
previewEmailTemplate(templateId, data?: PreviewEmailTemplateInput): Promise<EmailTemplatePreview>Render a preview

OAuth provider configuration

MethodPurpose
getOAuthProviders(): Promise<OAuthProviderConfig[]>List configured providers
getOAuthProvider(provider: OAuthProviderType): Promise<OAuthProviderConfig>Read one
configureOAuthProvider(data: ConfigureOAuthProviderInput): Promise<OAuthProviderConfig>Add one
updateOAuthProvider(provider, data: UpdateOAuthProviderInput): Promise<OAuthProviderConfig>Update it
deleteOAuthProvider(provider): Promise<void>Remove it

Storefront bot

MethodPurpose
getBotSettings(): Promise<BotSettingsConfigResponse>Read the bot configuration
updateBotSettings(data: UpdateBotSettingsDto): Promise<BotSettings>Update it
listBotConversations(opts?: { salesChannelId?, page?, limit? }): Promise<BotConversationsPage>Conversation log
getBotConversation(conversationId): Promise<BotConversationDetail>One conversation
summarizeBotConversation(conversationId): Promise<SummarizeBotConversationResult>AI summary

updateBotSettings changes only the fields you send. Beyond the greeting, tone, starter questions and capabilities, personaJson accepts:

  • languages and restrictLanguages. languages picks the chat window's own labels; the bot answers in the shopper's language unless restrictLanguages is true, in which case it answers only in languages. Off by default.
  • storeFacts (let the bot read shipping zones and rates, the free-shipping threshold, payment methods and contact details from the store's settings; off by default), knowledgePageIds (up to 5 published content pages it may answer from) and rules (up to 20 { kind: 'always' | 'never' | 'handoff', text } rules, 200 characters each; handoff rules apply only while the contact form is on).
  • memory (enabled, kinds, days 30 to 365, consentMode: 'ask' | 'implicit'): customer memory, off by default, signed-in shoppers only and only after they agree in ask mode.

Outside personaJson: escalationJson (BotEscalationConfig: enabled, defaultSubject, notifyEmails up to 5, handoverMessage), retentionJson ({ days }, 7 to 3650; a daily job then deletes older conversations and their messages for good, and omitting it keeps them forever) and dailySpendCeilingUsd (omit or 0 for no ceiling). BotCapabilities.cartRecovery is ignored. The widget that shoppers see (brainerce/bot, getBotSession(), and the getCartProof / onCartUpdated options) is covered in the Storefront bot guide.

Order custom fields

Merchant-defined fields that hold a value on an order. Admin mode only; the API key needs orders:read to read and orders:write to write.

MethodPurpose
getOrderCustomFieldDefinitions(): Promise<OrderCustomFieldDefinition[]>List the definitions (inactive ones included). The key of each is what the write accepts
getOrderCustomFieldValues(orderId): Promise<OrderCustomFieldValues>Read the values stored on one order
setOrderCustomFieldValues(orderId, fields, options?: IdempotentRequestOptions): Promise<OrderCustomFieldValues>Merge values onto an order. null clears a field that is not required; a key with no active definition is ignored, so read the returned fields to confirm

Donations

Available in sales-channel and storefront modes. A donation does not go through the cart: createDonation returns a PENDING donation, and the gift is real only once getDonation reads it back as PAID.

MethodPurpose
createDonation(input: CreateDonationInput): Promise<DonationIntent>Start a donation. Send one clientRequestId per form attempt so a double submit opens one payment
redirectToDonationPayment(donation: DonationIntent, options?: { extraPaymentHosts? }): voidBrowser only: send the donor to the provider's hosted payment page
getDonation(donationId): Promise<PublicDonation>Read a donation back for a thank-you page and to see whether it is paid
getDonationOptions(options?: { locale? }): Promise<DonationOptions>The funds and dedication wording a donation form offers
getDonationCampaigns(options?: { locale? }): Promise<{ data: DonationCampaign[] }>Visible campaigns (active and paused), newest first, at most 50
getDonationCampaign(slug, options?: { locale? }): Promise<DonationCampaign>One campaign with its progress. A draft or archived campaign is a 404 (campaign_not_found)
requestDonorManageLink(email): Promise<{ accepted: true }>Email a donor the link to manage monthly donations. Always answers the same way

Translations (admin)

Admin mode only; each call takes the storeId.

MethodPurpose
getTranslationStatus(storeId, locales?: string[]): Promise<TranslationStatusEntry[]>Translation completeness per entity type and locale
getTranslations(storeId, entityType, entityId): Promise<TranslationsMap>Every stored translation of one entity, keyed by locale
setTranslation(storeId, entityType, entityId, locale, fields): Promise<TranslationsMap>Set one locale's translation; only the fields valid for the entity type are stored
deleteTranslation(storeId, entityType, entityId, locale): Promise<void>Delete one locale's translation; the default-locale fields are untouched
aiTranslateSingle(storeId, input: AiTranslateSingleInput): Promise<TranslationsMap>AI-translate one entity into one locale, inline; fills only fields still empty for that locale
aiTranslateBulk(storeId, input: AiTranslateBulkInput): Promise<AiTranslateBulkResult>Queue one background job per entity; the result is the number of jobs queued, not the finished translations

Other methods

MethodPurpose
getInventory(productId): Promise<ProductInventoryResponse>Admin mode, inventory:read. A product with no inventory row reads back as four zero counters (total, reserved, unavailable, available), not a 404. available = total - reserved - unavailable
createReturnLabel(storeId, orderId, data: CreateReturnLabelDto): Promise<CreateReturnLabelResponse>Admin mode, FULFILL_ORDERS. Buy a return label the merchant sends to the customer; spends the carrier balance
getGoogleCustomerReviewsSurvey(checkoutId): Promise<GoogleCustomerReviewsSurveyData>The payload Google's Customer Reviews opt-in module needs for a just-completed checkout
getStoreInvitationByToken(token): Promise<StoreInvitationDetails>Public invitation details by token. Reachable with an API key only (admin mode)

Account-level team (deprecated)

getTeamMembers(), getTeamInvitations(), inviteTeamMember(), resendTeamInvitation(), revokeTeamInvitation(), updateTeamMemberRole() and removeTeamMember() are marked @deprecated in the SDK. They target the account team, not any single store's team, and today every one of them throws a BrainerceError without sending a request.

These seven do not work with an API key, and never have. team:read and team:write are not API-key scopes, and the /v1/team/* routes have no signed-in user to resolve. The store-level equivalents (getStoreTeam, inviteStoreMember, updateStoreMember, updateStoreMemberSalesChannels, removeStoreMember, resendStoreInvitation, revokeStoreInvitation, acceptStoreInvitation, getMyStores and getMyStorePermissions) are not a workaround either: they sit behind a dashboard-only guard that rejects API-key callers by design, so they throw a 403 BrainerceError without a request. getStoreInvitationByToken is the one invitation method that does work: it reads public invitation details by token.

To manage a team programmatically, use the MCP admin team tools. They take an OAuth user rather than an API key, and they work today.

Managing a store's team, meaning roles, permissions and per-sales-channel scoping, is a dashboard operation with no SDK path at all. Use these account-level methods for the account team, and the dashboard for store teams.


Part 3: Package-level exports

Everything importable from brainerce that is not a method on the client.

import {
  BrainerceClient,
  BrainerceError,
  SDK_VERSION,
  // webhooks
  verifyWebhook,
  parseWebhookEvent,
  isWebhookEventType,
  createWebhookHandler,
  DEFAULT_WEBHOOK_TOLERANCE_MS,
  // money
  formatPrice,
  formatMoney,
  formatProductPrice,
  formatVariantPrice,
  getProductPrice,
  getProductPriceInfo,
  getVariantPrice,
  getCartTotals,
  // catalog helpers
  getStockStatus,
  getVariantOptions,
  getProductSwatches,
  getDescriptionContent,
  isHtmlDescription,
  stripHtml,
  deriveSeoDescription,
  getCartItemName,
  getCartItemImage,
  isCouponApplicableToProduct,
  getProductMetafield,
  getProductMetafieldValue,
  getProductMetafieldsByType,
  getProductCustomizationFields,
  // SEO
  buildProductJsonLd,
  buildCollectionPageJsonLd,
  buildArticleJsonLd,
  buildOrganizationJsonLd,
  buildWebsiteJsonLd,
  buildBreadcrumbJsonLd,
  buildProductFaqJsonLd,
  jsonLdScriptProps,
  getProductSitemapEntries,
  getCategorySitemapEntries,
  getBlogSitemapEntries,
  // date/time custom fields
  validateDateAvailabilityConfig,
  resolveStoreLocalParts,
  isCalendarDateAllowed,
  computeAvailableSlots,
  getBusinessHoursForDate,
  isDateValueAllowed,
  parseDateFieldValue,
  resolveRelativeBounds,
  // safety
  isAllowedPaymentUrl,
  safePaymentRedirect,
  enableDevGuards,
  // payment render-mode negotiation
  resolveRenderType,
  // the payment step (client.startPayment wraps startPayment)
  startPayment,
  relayPaymentReturn,
  completePaymentReturn,
  PaymentFlowError,
  isAllowedPaymentScriptUrl,
  PAYMENT_PAGE_HOSTS,
  // payment widgets (lower level; startPayment uses them)
  mountPaymentWidget,
  preloadPaymentWidget,
  paymentWidgetMessage,
  PAYMENT_WIDGET_TRANSIENT_ERRORS,
  // i18n
  getDirectionForLocale,
  RTL_LOCALES,
} from 'brainerce';

Webhooks

Receivers for the merchant webhook contract: X-Brainerce-Signature is a hex HMAC-SHA256 of ${timestamp}.${rawBody}, X-Brainerce-Timestamp is epoch milliseconds, and the body is { id, type, createdAt, data }.

ExportPurpose
verifyWebhook({ rawBody, signature, timestamp, secret, toleranceMs? }): booleanRecomputes the HMAC over the timestamp-prefixed raw body, compares constant-time, rejects a timestamp more than toleranceMs (default 5 min) off either way. Never throws; false on any bad input
parseWebhookEvent(payload: string | Buffer | object): WebhookEventParse and type the envelope. Throws if it is not { id, type, createdAt, data }; call it after verifyWebhook
isWebhookEventType(event, type: WebhookEventType): booleanevent.type === type; a plain boolean, not a type predicate
createWebhookHandler(handlers): (payload: unknown) => Promise<void>Dispatch table keyed by type; takes the raw body or a parsed object
DEFAULT_WEBHOOK_TOLERANCE_MS300000, the default replay window
export async function POST(req: Request) {
  const rawBody = await req.text(); // the bytes, not req.json()
  const ok = verifyWebhook({
    rawBody,
    signature: req.headers.get('x-brainerce-signature'),
    timestamp: req.headers.get('x-brainerce-timestamp'),
    secret: process.env.BRAINERCE_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response('Invalid signature', { status: 401 });
  const event = parseWebhookEvent(rawBody); // { id, type, createdAt, data }
  // ... deduplicate on event.id, then act on event.type
  return new Response('OK');
}

Pass the raw bytes. A body that went through req.json() and back through JSON.stringify is not what was signed, and the check fails. In Express mount express.raw({ type: 'application/json' }) and pass req.body as rawBody.

Upgrading from SDK 2.9.0 or earlier. The old positional verifyWebhook(payload, signature, secret) hashed the body alone and ignored the timestamp, so it returned false for every real delivery; it still compiles for one release, with a one-time console warning, but cannot verify anything. WebhookEvent was { event, storeId, entityId, platform, data, timestamp }, a shape nothing sends; it is now the envelope above. Read event.type, and the entity id from event.data (data.orderId, data.productId).

See Verifying signatures for the same check hand-rolled in five languages, and the recipe.

Date and time availability

For DATE / DATETIME customization fields: feed a definition's dateAvailability config and the store timezone from getStoreInfo() into isCalendarDateAllowed, computeAvailableSlots, getBusinessHoursForDate, isDateValueAllowed, parseDateFieldValue, resolveStoreLocalParts and validateDateAvailabilityConfig. These are headless, with no JSX, so bring your own calendar.

Development guards

enableDevGuards() installs runtime proxies that shout when you read a price as a number or reach for a field that does not exist. Development only.

Types

Every type named on this page is exported from brainerce. Import them rather than hand-writing your own copies, because the shipped .d.ts is the source of truth and it moves. A call's return type can also be inferred (const zone = await client.getRegions()), which follows the SDK when it changes; a hand-written copy of a shape silently drifts.

Admin input types are exported as …Input where the SDK's own name ends in Dto. Those *Dto names are aliased on the package surface, so import type { CreateTaxRateDto } from 'brainerce' fails to compile while CreateTaxRateInput works. The aliased ones are CreateCategoryInput, UpdateCategoryInput, CreateBrandInput, UpdateBrandInput, CreateTagInput, UpdateTagInput, CreateAttributeInput, UpdateAttributeInput, CreateAttributeOptionInput, UpdateAttributeOptionInput, CreateShippingZoneInput, UpdateShippingZoneInput, CreateShippingRateInput, UpdateShippingRateInput, CreateReturnLabelInput, CreateTaxRateInput, UpdateTaxRateInput, CreateMetafieldDefinitionInput, UpdateMetafieldDefinitionInput, SetDefinitionProductsInput, SetMetafieldPlatformsInput, UpsertProductMetafieldInput, InviteMemberInput, UpdateMemberRoleInput, InviteStoreMemberInput, UpdateStoreMemberInput, UpdateEmailSettingsInput, CreateEmailTemplateInput, UpdateEmailTemplateInput, PreviewEmailTemplateInput, ResolveSyncConflictInput, ResolveMetafieldConflictInput, ConfigureOAuthProviderInput and UpdateOAuthProviderInput. Every other *Dto (CreateRegionDto, UpdateRegionDto, CreateTaxClassDto, UpdateTaxClassDto, AssignTaxClassDto, CreateShipmentDto, PublishProductDto, UpdateBotSettingsDto and so on) is exported under its own name. TypeScript will suggest the right spelling if you reach for the wrong one.


Not on this page

Deliberate omissions, so you know they are absent on purpose:

  • The legacy localStorage cart: getLocalCart, addToLocalCart, updateLocalCartItem, removeFromLocalCart, clearLocalCart, setLocalCartCustomer, setLocalCartShippingAddress, setLocalCartBillingAddress, setLocalCartCoupon, getLocalCartItemCount, removeLocalCartItemsByIndex. All @deprecated and superseded by server-side session carts. They still exist for backward compatibility; the smart* family is the supported surface.
  • Methods with no reachable endpoint. A number of client methods target paths that the public API does not expose. Rather than document a call that returns 404, they are left out. If you need one of those operations today, use the dashboard. These methods exist on the client but throw a BrainerceError (400) in every mode without sending a request, because the API-key /v1 surface has no route behind them: publishProduct, updateOrderStatus, updatePaymentMethod, updateOrderNotes, updateOrderShipping, cancelOrder, fulfillOrder, getOrderRefunds, createRefund, syncDraftOrders, updateDraftOrder, completeDraftOrder, sendDraftInvoice, deleteDraftOrder, editInventory, getInventorySyncStatus, getBulkInventory, reconcileInventory, updateAttributeOption, deleteAttributeOption, publishMetafieldDefinitionToVibeCodedSite, unpublishMetafieldDefinitionFromVibeCodedSite, setDefinitionProducts, getProductCustomizationFields (the client method; the exported helper of the same name works) and setProductCustomizationFields. Refunds, cancellations, fulfilment and draft orders are dashboard (or MCP admin) operations today.
  • Methods that always fail or do nothing, by design. getSyncConflicts and resolveSyncConflict throw a 501 without a request; triggerSync and getSyncStatus answer 501; syncCoupon and publishCoupon are no-ops; getCouponPlatformCapabilities and getMetafieldConflicts return an empty result; resolveMetafieldConflict and ignoreMetafieldConflict answer 404; setMetafieldPlatforms only records a list. The full list with the alternative for each is the "Deprecated Methods" table in the package README. Use publishProductToSalesChannel (instead of publishProduct) and updateOrder where they cover the case.

Where to go next