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 brainerceimport { 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-sdkpackage. 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.
| Mode | Option | Where it runs | What it can reach |
|---|---|---|---|
| Channel | salesChannelId: 'vc_…' | Browser or server | The full storefront surface, plus channel-only extras. The default. |
| Store | storeId: 'store_…' | Browser or server | Public catalog plus cart, checkout, orders and customer registration |
| Admin | apiKey: 'brainerce_…' | Server only | Catalog 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()andwaitForOrder(), is sales-channel only. AstoreIdclient can reachcompleteCheckout()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.
salesChannelIdis the mode the storefront feature checklist assumes. PickstoreIdonly 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.
| Method | Purpose |
|---|---|
isAdminMode(): boolean | True when constructed with apiKey |
isSalesChannelMode(): boolean | True when constructed with salesChannelId and no apiKey |
isStorefrontMode(): boolean | True when constructed with storeId only |
isVibeCodedMode(): boolean | Deprecated alias of isSalesChannelMode() |
setCustomerToken(token: string | null): void | Attach a logged-in customer's token |
getCustomerToken(): string | null | Read the attached token |
clearCustomerToken(): void | Detach it |
isCustomerLoggedIn(): boolean | Whether a customer token is attached |
setLocale(locale: string | undefined): void | Set the locale sent with every read |
getLocale(): string | undefined | Read it back |
getStoreDirection(locale?: string | null): 'ltr' | 'rtl' | Document direction for <html dir> |
setCustomerToken()is a plain setter. Always follow it withawait 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 itsidcan differ from the guest cart's: render that cart, and refresh your cart UI once it resolves. Do not fire it withoutawaitbeside 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
| Method | Purpose | Modes |
|---|---|---|
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 ?? logo | Channel, Store |
getStoreCapabilities(): Promise<StoreCapabilities> | What the merchant configured on this channel: store, connection, features | Channel |
initTracking(tracking?: StoreTracking | null): void | Wire GA4 / GTM / Meta / TikTok tags from store settings. Sync | Local |
trackMarketingEvent(name, payload?): void | Emit a marketing event to the wired tags. Sync | Local |
trackEvent(payload?: TrackEventPayload): Promise<void> | First-party analytics beacon | Local |
loadGoogleAnalytics(measurementId, options?): void | Inject the GA script directly. Sync | Local |
updateConsent(consent: { analytics?: boolean; ads?: boolean }): void | Record a shopper's Consent Mode choice and push it to every wired tag. Sync | Local |
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 noStoreInforead behind anapiKey:GET /api/v1/storereturns only{ id, accountId, connectedPlatforms }, so the SDK throws aBrainerceError400 rather than hand back a shape that looks likeStoreInfobut isn't. Call it from asalesChannelIdorstoreIdclient only.
getStoreCapabilities()is the method for the capabilities payload. It callsGET /api/vc/{salesChannelId}/capabilitiesfor you, so do not hand-roll afetch. AI agents get the same payload from theget-store-capabilitiesMCP tool.getStoreInfo()overlaps with itsstoreandconnectionblocks but never carriesfeatures, 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: instoreIdor admin mode there is no single channel to read, so the call throws a 400BrainerceErrorand 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
| Method | Purpose | Modes |
|---|---|---|
getProducts(params?: ProductQueryParams): Promise<PaginatedResponse<Product>> | Paginated catalog with category / brand / tag / price / metafield filters & sort | Channel, Store, Admin |
getProduct(productId, options?: { locale?, regionId? }): Promise<Product> | One product by id | Channel, Store, Admin |
getProductBySlug(slug, options?: { locale?, regionId? }): Promise<Product> | One product by slug, the PDP call | Channel, Store |
getSearchSuggestions(query: string, limit?: number): Promise<SearchSuggestions> | Autocomplete: matching products + categories | Channel only |
getPublicMetafieldDefinitions(): Promise<{ definitions: PublicMetafieldDefinition[] }> | Custom-field definitions visible to the storefront | Channel, Store, Admin |
getMetafieldFilters(params?: { locale? }): Promise<MetafieldFiltersResponse> | Facet values with product counts, for filter UI | Channel, 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 alimitit defaults to the number of ids. It is whatclient.wishlist.resolve()calls (see section 22).
getSearchSuggestions()throws instoreIdandapiKeymode. If you are building on a publicstoreId, filter withgetProducts({ search })instead.
getProducts()filters less in admin (apiKey) mode.GET /api/v1/productshonourspage,limit,search,status,type,sortBy,sortOrderandregionIdonly.categories,brands,tags,minPrice,maxPriceandmetafieldsare 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):
| Export | Purpose |
|---|---|
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 aKIT. A kit carries noinventoryobject at all, so passingproduct.inventoryhands the helperundefinedand you get an "in stock" reading for a kit that may be sold out. Branch onproduct.type === 'KIT'and readkitAvailableinstead: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 novariantId(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, notinventory. There is noinventoryblock on a kit.null= unlimited,0= not sellable (including a kit with no components). Treating a missinginventoryas "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:
| Method | Purpose | Modes |
|---|---|---|
getProductAlternates(productId): Promise<{ alternates: Array<{ locale, slug }> }> | Per-locale slugs for <link rel="alternate"> | Store only |
4. Product reviews
| Method | Purpose | Modes |
|---|---|---|
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 first | Channel, Store |
getMyProductReview(productId): Promise<MyProductReview> | Drives the form state: eligible / already reviewed / not a purchaser | Channel, Store |
submitProductReview(productId, input: WriteProductReviewInput): Promise<ProductReview> | Post a review (purchaser only) | Channel, Store |
updateMyProductReview(productId, input: WriteProductReviewInput): Promise<ProductReview> | Edit your own | Channel, Store |
deleteMyProductReview(productId): Promise<void> | Delete your own | Channel, Store |
uploadReviewPhoto(productId, file: File | Blob): Promise<ReviewPhotoUpload> | Upload one photo, then pass its key in imageKeys on submit | Channel, 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
| Method | Purpose | Modes |
|---|---|---|
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.
| Method | Purpose | Modes |
|---|---|---|
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 cart | Local |
smartGetCart(options?: CartIncludeOptions): Promise<CartWithIncludes> | Fetch the current cart | Local |
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(): number | Badge 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().itemCount | Local |
syncCartOnLogin(): Promise<Cart> | Attach the guest cart to the customer after login. Resolves with the account's cart after the merge | Local |
onLogout(): void | Drop the cached customer cart. Sync | Local |
onCheckoutComplete(): void | Reset cart caching after a completed order. Sync | Local |
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.code | Status | What the SDK does |
|---|---|---|
CART_NOT_FOUND | 404 | Resolves 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_FOUND | 404 | Remove: returns the current cart. Update: throws it to you |
PRODUCT_NOT_FOUND, VARIANT_NOT_FOUND | 404 | Throws untouched. The cart is fine |
CART_ACCESS_DENIED | 403 | Throws. 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) | 404 | Reads 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 field | Meaning |
|---|---|
status | loading, empty (only after the server said so), ready, error (the first load failed; retry() leaves it) |
cart | The optimistic view: the last confirmed cart with pending quantity and removal changes applied. null while loading or error |
itemCount, hintCount | Units 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, totalsStale | A 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 |
canCheckout | ready, nothing pending or syncing, no unavailable line, reservation not expired |
loadError, mutationError | The last failed load or refresh; the last failed (already rolled back) write. dismissMutationError() clears the latter |
epoch | Bumped 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 changesCartProvider 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)
| Method | Purpose | Modes |
|---|---|---|
createCart(options?: CreateCartOptions): Promise<Cart> | Create an empty cart | Channel, Store, Admin |
getCart(cartId, options?: CartIncludeOptions): Promise<CartWithIncludes> | Fetch by id, optionally with includes | Channel, Store, Admin |
getCartBySession(sessionToken): Promise<Cart> | Resolve a guest cart from its session token | Channel, Store, Admin |
getHeldCartProof(): { cartId, cartToken } | null | The 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.0 | Channel, Store |
addToCart(cartId, item: AddToCartDto): Promise<Cart> | Add a line | Channel, Store, Admin |
updateCartItem(cartId, itemId, data: UpdateCartItemDto): Promise<Cart> | Change a line | Channel, Store, Admin |
removeCartItem(cartId, itemId): Promise<Cart> | Remove a line | Channel, Store, Admin |
clearCart(cartId): Promise<Cart> | Empty the cart | Channel, Store, Admin |
linkCart(cartId): Promise<Cart> | Attach the cart to the logged-in customer; merges into an existing account cart and returns that cart | Channel, Store |
getMyCart(): Promise<Cart> | The signed-in customer's cart | Store only |
recalculateCart(cartId): Promise<Cart> | Re-price the cart against current product prices | Channel only |
refreshCartSnapshots(cartId): Promise<Cart> | Refresh per-item unitPrice snapshots to live prices | Channel only |
getCartByCustomer(customerId): Promise<Cart> | A given customer's cart | Admin only |
mergeCarts(data: MergeCartsDto): Promise<Cart> | Merge a guest cart into a customer cart | Admin 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
| Method | Purpose | Modes |
|---|---|---|
applyCoupon(cartId, code): Promise<Cart> | Apply a code on the cart page | Channel, Store, Admin |
removeCoupon(cartId): Promise<Cart> | Remove it | Channel, Store, Admin |
applyCheckoutCoupon(checkoutId, code): Promise<Checkout> | Apply on the checkout page | Channel, Admin |
removeCheckoutCoupon(checkoutId): Promise<Checkout> | Remove it | Channel, 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):
| Method | Purpose | Modes |
|---|---|---|
getDiscountBanners(): Promise<DiscountBanner[]> | Active promotion banners for the header | Channel, Store |
getProductDiscountBadge(productId): Promise<ProductDiscountBadge | null> | Badge for a product card / PDP | Channel, Store |
getCartNudges(cartId): Promise<CartNudge[]> | "Add $8 more for free shipping" prompts | Channel, 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
| Method | Purpose | Modes |
|---|---|---|
checkCartStock(cart, selectedItemIds?): Promise<StockAvailabilityResponse> | Pre-flight the whole cart (pass the cart object, or its id); wraps the Channel-only primitive below | Channel only |
checkStockAvailability(items: StockAvailabilityRequest[]): Promise<StockAvailabilityResponse> | The primitive: check specific items | Channel only |
getAvailability(productIds: string[]): Promise<ProductAvailability[]> | Available vs reserved quantities per product | Channel only |
extendReservation(options: { cartId?, checkoutId? }): Promise<ExtendReservationResponse> | Keep a reservation alive | Channel only |
releaseReservation(options: { cartId?, checkoutId? }): Promise<void> | Hand stock back immediately | Channel 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.
| Method | Purpose |
|---|---|
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?): () => void | One 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
| Method | Purpose | Modes |
|---|---|---|
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 it | Channel, Store, Admin |
setCheckoutCustomer(checkoutId, data: SetCheckoutCustomerDto): Promise<Checkout> | Attach customer details and order notes | Channel, 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 address | Channel, Store, Admin |
selectShippingMethod(checkoutId, shippingRateId): Promise<Checkout> | Pick a rate | Channel, 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 support | Channel, 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 reservation | Channel, 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 values | Channel, Store |
getShippingDestinations(): Promise<ShippingDestinations> | Countries and regions the store ships to | Channel 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.
| Method | Purpose | Modes |
|---|---|---|
applyGiftCard(checkoutId, code): Promise<CheckoutTender> | Apply a card to the checkout | Channel, Store, Admin |
removeGiftCard(checkoutId, tenderId): Promise<{ removed, providerAmountDue }> | Remove one, by tenderId | Channel, Store, Admin |
checkGiftCardBalance(code): Promise<GiftCardBalance> | { balance, currency, usable } — optional pre-check | Channel, 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
| Method | Purpose | Modes |
|---|---|---|
getAddressSuggestions(query, sessionToken, near?: { lat, lng }): Promise<AddressSuggestion[]> | Typeahead suggestions | Channel, Store |
getAddressDetails(placeId, sessionToken, options?: { regionId? }): Promise<AddressDetailsResult> | Resolve a suggestion to a full address | Channel, Store |
Pass
placeIdthrough tosetShippingAddress(). 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 samesessionTokenfor the suggestion and the details call.
Pickup instead of delivery
| Method | Purpose | Modes |
|---|---|---|
getPickupLocations(): Promise<PickupLocation[]> | The store's pickup points | Channel, Store |
setDeliveryType(checkoutId, deliveryType: 'shipping' | 'pickup'): Promise<Checkout> | Switch the mode | Channel, Store |
selectPickupLocation(checkoutId, data: SelectPickupLocationDto): Promise<Checkout> | Choose a point | Channel, Store |
11. Payment
| Method | Purpose | Modes |
|---|---|---|
startPayment(checkoutId, options: StartPaymentOptions): PaymentSession | The whole payment step, for every provider. Creates the intent and shows it. Browser only | Channel only |
completePaymentReturn(checkoutId, options?: { search?, maxWaitMs? }): Promise<PaymentReturnResult> | On /order-confirmation: hand the provider's return parameters to the server and wait for the order | Channel only |
getPaymentProviders(): Promise<PaymentProvidersConfig> | Every installed provider plus the default, which is what you render | Channel only |
createPaymentIntent(checkoutId, options?): Promise<PaymentIntent> | Start the charge. Options: providerId, successUrl, cancelUrl, saveCard, preferredRenderType, expectedTotal | Channel only |
getPaymentStatus(checkoutId): Promise<PaymentStatus> | Poll the charge state | Channel only |
confirmSdkPayment(checkoutId, providerResponseData?: Record<string, unknown>): Promise<{ confirmed: boolean }> | Confirm a client-side SDK payment after the provider returns | Channel 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'| Export | Purpose |
|---|---|
startPayment(client, checkoutId, options): PaymentSession | Standalone form of client.startPayment |
relayPaymentReturn(options?: { confirmationPath? }): void | For /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 |
PaymentFlowError | onError'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.
| Export | Purpose |
|---|---|
resolveRenderType(clientSdk, preferred?): RenderType | The 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.
| Export | Purpose |
|---|---|
mountPaymentWidget(intent, handlers: PaymentWidgetHandlers, options?): PaymentWidgetHandle | Open 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): string | The provider's message from whatever shape it answered with |
PAYMENT_WIDGET_TRANSIENT_ERRORS | The 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):
| Export | Purpose |
|---|---|
isAllowedPaymentUrl(url, options?): boolean | Is this URL on the payment page allowlist? |
safePaymentRedirect(url, options?): void | Navigate only if it is; blocks open-redirect abuse |
isAllowedPaymentScriptUrl(url, options?): boolean | Is this widget script https and on the script allowlist? options.extraHosts adds hosts |
PAYMENT_PAGE_HOSTS | The payment page allowlist (also what buildStorefrontCsp puts in frame-src) |
buildStorefrontCsp({ nonce, apiOrigin, isDev? }): string | The storefront CSP. frame-src covers every payment host plus apiOrigin (Brainerce embeds) |
12. Order confirmation
| Method | Purpose | Modes |
|---|---|---|
handlePaymentSuccess(checkoutId?): { cleared, mode, userType, itemsRemoved? } | Clear the right cart for guest or customer, full or partial checkout. Synchronous | Local |
waitForOrder(checkoutId, options?: WaitForOrderOptions): Promise<WaitForOrderResult> | Poll until the webhook has created the real order | Channel only |
getOrderByCheckout(checkoutId): Promise<Order> | Fetch the order once it exists | Channel, 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
| Method | Purpose | Modes |
|---|---|---|
startGuestCheckout(options?: { selectedIndices?: number[] }): Promise<GuestCheckoutStartResponse> | Open a guest checkout | Channel only |
updateGuestCheckoutAddress(checkoutId, data: { shippingAddress?, billingAddress? }): Promise<Checkout> | Set the addresses | Channel only |
completeGuestCheckout(checkoutId, options?: { clearCartOnSuccess?, selectedIndices? }): Promise<GuestOrderResponse> | Finish it | Channel only |
getActiveGuestCheckout(): { checkoutId, cartId, selectedIndices? } | null | Resume an in-flight guest checkout. Synchronous | Local |
clearActiveGuestCheckout(): void | Forget it without clearing the cart. Synchronous | Local |
submitGuestOrder(),createGuestOrder()andcreateOrder()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
| Method | Purpose | Modes |
|---|---|---|
getOrderDownloads(orderId, options?: { checkoutId? }): Promise<OrderDownloadLink[]> | Signed links for a signed-in buyer | Channel, Store |
getGuestOrderDownloads(email, orderNumber): Promise<OrderDownloadLink[]> | Signed links for a guest | Channel, Store |
13. Customer authentication
| Method | Purpose | Modes |
|---|---|---|
registerCustomer(data: RegisterCustomerDto): Promise<CustomerAuthResponse> | Create an account. Handle the requiresVerification branch. Accepts birthMonth / birthDay | Channel, Store, Admin |
loginCustomer(email, password): Promise<CustomerAuthResponse> | Log in. Also returns requiresVerification | Channel, 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 400 | Channel, Store, Admin |
resetPassword(token, newPassword): Promise<{ message: string }> | Consume the emailed token | Channel, Store, Admin |
verifyEmail(code: string, token?: string): Promise<EmailVerificationResponse> | Submit the 6-digit code | Channel only |
resendVerificationEmail(token?: string): Promise<{ message: string; token?: string }> | Resend it | Channel 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)
| Method | Purpose | Modes |
|---|---|---|
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 browser | Channel, Store |
handleOAuthCallback(provider, code, state): Promise<OAuthCallbackResponse> | Exchange the callback params for a session | Channel, 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 bar | Any mode |
linkOAuthProvider(provider, options?: { redirectUrl? }): Promise<OAuthAuthorizeResponse> | Attach a provider to an existing account | Channel, Store |
unlinkOAuthProvider(provider): Promise<{ success: boolean }> | Detach it | Channel, Store |
getOAuthConnections(): Promise<OAuthConnectionsResponse> | Which providers this customer has linked | Channel, 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
| Method | Purpose | Modes |
|---|---|---|
getMyProfile(): Promise<CustomerProfile> | The signed-in customer's profile, including birthMonth / birthDay | Channel, Store |
updateMyProfile(data): Promise<CustomerProfile> | firstName, lastName, phone, acceptsMarketing, birthMonth, birthDay | Channel, 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 segment | Channel, Store |
getMyOrders(params?: { page?, limit? }): Promise<PaginatedResponse<Order>> | Paginated order history | Channel, 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 customer | Channel, Store |
getMyAddresses(): Promise<CustomerAddress[]> | Saved addresses | Channel, Store |
addMyAddress(address: CreateAddressDto): Promise<CustomerAddress> | Add one | Channel, Store |
updateMyAddress(addressId, data: UpdateAddressDto): Promise<CustomerAddress> | Edit one | Channel, Store |
deleteMyAddress(addressId): Promise<void> | Remove one | Channel, Store |
getMySavedPaymentMethods(): Promise<StorefrontSavedPaymentMethod[]> | Vaulted cards for this customer | Channel, Store |
Logout is clearCustomerToken() plus onLogout().
Birthday fields.
birthMonth(1-12) andbirthDay(1-31) carry no year, must be written together, and are returned bygetMyProfile()and bygetCheckoutPrefillData().customer, so pre-fill the picker rather than rendering it empty. Both are optional unlessgetStoreInfo().requireBirthdayistrue(an absent field meansfalse, and the same flag appears asconnection.requireBirthdayin the capabilities payload described in section 1), which makes them mandatory at registration. That flag is enforced onvc_*sales-channel registration only. A storefront connected by plainstoreIdhas no channel to read it from, so it is not enforced there, the same reach limitationrequireEmailVerificationhas, 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: falsecarries areason:RETURNS_DISABLED,NOT_SHIPPEDorWINDOW_EXPIRED. Per line,returnableQuantityand a blockreason(GIFT_CARD,DIGITAL,BUNDLE,FINAL_SALE,ALREADY_RETURNED).- The server checks the rules again on every
requestReturn(); a refusal is a 400 whoseerror.codeis the rule (alsoQUANTITY_EXCEEDS_RETURNABLE). AclientRequestIdis filled for you; the same one again replays the first return. statusisREQUESTED,APPROVED,DECLINED(showdeclineMessage),RECEIVED,COMPLETEDorCANCELLED.cancelMyReturn()works only whileREQUESTED(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
| Method | Purpose | Modes |
|---|---|---|
getLoyaltyStatus(): Promise<LoyaltyStatus> | Points (incl. pendingPoints), tier, progress | Channel, Store |
enrollInLoyalty(): Promise<LoyaltyStatus> | Opt in | Channel, Store |
getAvailableRewards(): Promise<LoyaltyReward[]> | What the customer can redeem | Channel, Store |
getRecommendedReward(): Promise<LoyaltyRewardRecommendation> | The single best next reward | Channel, Store |
redeemLoyaltyReward(rewardId): Promise<RedeemRewardResult> | Redeem one | Channel, Store |
reportSocialShare(platform?: string): Promise<{ awarded: boolean; points: number }> | Award share points | Channel, Store |
getReferralInfo(code: string): Promise<ReferralInfo> | Resolve a referral code | Channel, Store |
getMembershipPlans(): Promise<LoyaltyMembershipPlan[]> | Paid membership tiers | Channel, 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> | Subscribe | Channel, Store |
cancelMembership(): Promise<PaidMembershipInfo> | Cancel | Channel, Store |
getLoyaltyWidgetSession(): Promise<{ sessionId, expiresAt, storeId, embedUrl }> | Session for the embeddable widget | Channel, 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 onlyTypes: faq, footer, header, announcement, richText, page.
page adds getBySlug(slug, locale?) for a catch-all [slug] route.
| Call | Purpose | Modes |
|---|---|---|
content.header.get('main', locale?) | Logo, nav items, CTA | Channel, Store |
content.footer.get('main', locale?) | Columns, social links, copyright | Channel, Store |
content.announcement.list(locale?) | Announcement bar entries | Channel, Store |
content.faq.get('main', locale?) | FAQ accordion | Channel, Store |
content.page.getBySlug(slug, locale?) | A static page (About, Terms, Privacy…) | Channel, Store |
content.richText.get(key?, locale?) | A free-form HTML block | Channel, Store |
content.findById(id, storeId) | One row by admin id; throws on 404 | Admin only |
content.listAdmin({ storeId, type?, status? }) | Cross-type admin listing incl. drafts | Admin only |
content.<type>.create(input, storeId) | New row in DRAFT | Admin only |
content.update(id, input, storeId) | Replace data | Admin only |
content.publish(id, storeId) / content.unpublish(id, storeId) | DRAFT ↔ PUBLISHED | Admin only |
content.remove(id, storeId) | Hard delete | Admin only |
get()andgetBySlug()returnnullwhen the merchant has not seeded anything. Render a hard-coded fallback so the page never crashes. AndRICH_TEXT,PAGEand FAQ answers contain raw HTML that the server does not sanitize, because some merchants embed iframes on purpose. Sanitize beforedangerouslySetInnerHTML, every time.
Every Admin-only call takes an explicit
storeIdas its last argument. Admin mode has no ambient store (storeIdis 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 with403 STORE_SCOPE_REQUIREDbefore 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 thecontent:readscope for the admin reads andcontent:writefor 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:
| Call | Purpose | Modes |
|---|---|---|
blog.getPosts(params?: BlogPostListParams, storeId?) | Posts; filters category, tag, page, limit. Admin mode lists drafts too and requires storeId; the other modes ignore it | Channel, Store, Admin |
blog.getPost(slug) | One PUBLISHED post by slug, or null. Throws in Admin mode | Channel, Store |
blog.findById(id, storeId) | One post by admin id, drafts included, or null on 404 | Admin only |
blog.create(input, storeId) | New post in DRAFT | Admin only |
blog.update(id, input, storeId) | Edit | Admin only |
blog.publish(id, storeId) / blog.unpublish(id, storeId) | Status transitions | Admin only |
blog.duplicate(id, storeId) | Copy as a DRAFT titled " (Copy)" with a new slug; never published or dated | Admin only |
blog.remove(id, storeId) | Hard delete | Admin 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.
storeIdis required on every Admin call and travels as a query param, with the same403 STORE_SCOPE_REQUIREDfail-closed rejection as Content when it is missing or names a store the key is not bound to. The key needsblog:readfor the reads andblog:writefor the writes.getPost(slug)throws in Admin mode rather than issuing a request that could only 404, so reach forfindById(id, storeId), or find the post first withgetPosts({}, storeId). Note the mismatch between the twofindByIdmethods:blog.findByIdresolves tonullon 404, whilecontent.findByIdthrows.
Sitemap, redirects and structured data
| Method / export | Purpose | Modes |
|---|---|---|
getSitemapProducts(limit = 5000) | Lightweight { id, slug, updatedAt, localeSlugs } rows | Channel only |
resolveSlugRedirect(entityType: 'product' | 'blog', slug): Promise<{ currentSlug: string } | null> | Resolve a renamed slug to its current one | Channel only |
getProductSitemapEntries(client, opts): Promise<SitemapEntry[]> | Product URLs, via the dedicated endpoint | export |
getCategorySitemapEntries(client, opts): Promise<SitemapEntry[]> | Category URLs | export |
getBlogSitemapEntries(client, opts): Promise<SitemapEntry[]> | Blog URLs | export |
buildProductJsonLd(product, opts) | Product schema, PDP only | export |
buildCollectionPageJsonLd(category, opts) | CollectionPage schema, category pages | export |
buildArticleJsonLd(post, opts) | Article schema, blog posts | export |
buildOrganizationJsonLd(store, opts) | Organization schema, site-wide | export |
buildWebsiteJsonLd(store, opts) | WebSite schema, with optional search action | export |
buildBreadcrumbJsonLd(items) | BreadcrumbList schema | export |
buildProductFaqJsonLd(product) | FAQPage schema from product.faq, or null | export |
jsonLdScriptProps(data) | Props for the <script type="application/ld+json"> tag | export |
Build your product sitemap from
getProductSitemapEntries(), notgetProducts(). The listing API clampslimitto 100, so agetProducts({ 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
| Method | Purpose | Modes |
|---|---|---|
getCategories(options?: { locale? }): Promise<{ categories: CategoryNode[] }> | The category tree for navigation | Channel only |
getCategoryBySlug(slug, options?: { locale? }): Promise<CategoryDetail> | Category landing payload | Channel only |
getBrands(options?: { locale? }): Promise<{ brands: Array<{ id, name }> }> | Brand list for filters | Channel only |
getTags(options?: { locale? }): Promise<{ tags: Array<{ id, name, image }> }> | Tag list for filters and tag tiles | Channel 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
| Method | Purpose | Modes |
|---|---|---|
getStoreRegions(): Promise<{ data: PublicRegion[] }> | Regions the storefront may offer | Channel, Store |
getStoreRegion(regionId): Promise<PublicRegionDetail> | One region's detail | Channel, Store |
getAutoRegion(country?): Promise<AutoRegionResponse> | Best region for the visitor | Channel, Store |
detectRegion(country, regions): Region | PublicRegion | null | Local matcher over an already-fetched list. Synchronous | Local |
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 classes | Channel, 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.
| Call | Purpose | Modes |
|---|---|---|
contactForms.list(): Promise<ContactFormSummary[]> | Active forms (main, newsletter, …) | Channel, Store |
contactForms.get(formKey = 'main', locale?): Promise<ContactFormPublic> | Full schema with localized labels and validation | Channel, Store |
createInquiry(input: CreateInquiryInput): Promise<CreateInquiryResponse> | Submit | Channel, 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).
| Call | Purpose | Modes |
|---|---|---|
marketing.subscribe(input: SubscribeMarketingInput): Promise<SubscribeMarketingResponse> | Start a confirmed opt-in | Channel, 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).
| Call | Purpose | Modes |
|---|---|---|
stockAlerts.subscribe(input: CreateStockAlertInput): Promise<StockAlertResponse> | Ask to be told once when it's back | Channel, 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.wishlistis the sixth namespace and does not exist in earlier versions. Do not calladdToWishlist()orgetWishlist(): 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.
| Call | Purpose | Modes |
|---|---|---|
wishlist.keys(): Promise<WishlistKey[]> | The whole list, newest first, in one read | Channel, 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 whole | Channel, 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 skipped | Channel, 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 blocked | Local |
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 it | Channel, 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 returns | Channel, 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()andsyncOnLogin()throw on failure; render an error with a retry. - Only currently visible items are returned and counted.
keys(),list(),countandtotalleave 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()leaveswishlist.localalone). - Tracking:
trackMarketingEvent('add_to_wishlist', ...)sendsAddToWishlistto 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.
| Method | Purpose |
|---|---|
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): void | Browser 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
CONFIRMEDonly when the server has verified the payment. - Render
startsAtandendsAtinevent.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
clientRequestIdper 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
| Method | Purpose |
|---|---|
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 storagekey. An external URL is not akey, and a storedkeyis 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 callsetKitComponents(productId, { components })the kit has no price andaddToCartrejects it as unavailable. Create, then set components, in that order.getKitComponentsis 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):
| Mode | What the kit charges |
|---|---|
FIXED (default) | The kit's own basePrice / salePrice, unchanged when components reprice. |
SUM | Exactly what the components cost, recomputed on every read. A component going on sale lowers the kit price on its own. |
SUM_MINUS_PERCENT | That 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-1andmug-1are 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,salePriceandcostPriceon a product, andprice,salePriceandcostPriceon a variant (includingvariants[]insidecreateProduct): 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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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-4567and050-1234567pass; letters are refused, so'n/a'fails. An empty string is accepted. Nothing is normalised. The rule applies to these two methods only, not tosetCheckoutCustomer, the shopper's own profile (updateMyProfile) or file imports.firstNameandlastName: at most 100 characters each.role: at most 50.email: a valid address of at most 320 characters. Each entry oftags: at most 100.- A second customer in the store with the same
emailis 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.
| Method | Scope | Purpose |
|---|---|---|
listGiftCards(params?) | gift_cards:read | List. 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:read | The month-end figure, per currency |
getGiftCard(id) | gift_cards:read | One card with its full ledger |
issueGiftCard(data) | gift_cards:issue | Mint a card. Returns the code once |
reissueGiftCard(id, note) | gift_cards:issue | New code, whole balance moved, old card revoked |
adjustGiftCardBalance(id, delta, note) | gift_cards:adjust | Signed decimal: "25.00" adds, "-25.00" removes |
setGiftCardStatus(id, status) | gift_cards:write | ACTIVE / DISABLED / REVOKED |
bulkSetGiftCardStatus(giftCardIds, status, options?) | gift_cards:write | ACTIVE 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 chanceThere 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
| Method | Purpose |
|---|---|
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).
| Call | Route | Purpose |
|---|---|---|
newsletterBenefit.getSettings(): Promise<NewsletterBenefitSettings | null> | GET /v1/newsletter-benefit/settings | The 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/settings | Create 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/grants | Issued benefits, newest first |
newsletterBenefit.resend(grantId: string): Promise<ResendNewsletterBenefitResult | null> | POST /v1/newsletter-benefit/grants/{id}/resend | Re-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) andfirstOrderOnlyare 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) andcontent?({ 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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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.
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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.99passes,-5and9.999do not, andnullor an omitted value means "not set". That coversrateConfig.amount(FLAT_RATE,LOCAL_PICKUP),rateConfig.minOrderAmount(FREE),rateConfig.baseRate,perKg,overflowPerKgand everyweightTiers[].price(WEIGHT_BASED), everytiers[].minOrderAmountandtiers[].rate(PRICE_BASED), and the rate-levelminOrderAmount,maxCostand everyclassCostsamount (flat,firstandadditional). nameis 1 to 100 characters.minDeliveryDaysandmaxDeliveryDaysare whole numbers of 0 or more, andminDeliveryDayscannot be higher thanmaxDeliveryDays. An update that sends only one end is checked against the end already saved.classCalculationTypeisper_class,per_orderorper_item.- A
FREErate withrateConfig.minOrderAmountomitted or0is 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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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)
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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:
languagesandrestrictLanguages.languagespicks the chat window's own labels; the bot answers in the shopper's language unlessrestrictLanguagesistrue, in which case it answers only inlanguages. 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) andrules(up to 20{ kind: 'always' | 'never' | 'handoff', text }rules, 200 characters each;handoffrules apply only while the contact form is on).memory(enabled,kinds,days30 to 365,consentMode: 'ask' | 'implicit'): customer memory, off by default, signed-in shoppers only and only after they agree inaskmode.
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.
| Method | Purpose |
|---|---|
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.
| Method | Purpose |
|---|---|
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? }): void | Browser 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.
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
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 }.
| Export | Purpose |
|---|---|
verifyWebhook({ rawBody, signature, timestamp, secret, toleranceMs? }): boolean | Recomputes 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): WebhookEvent | Parse and type the envelope. Throws if it is not { id, type, createdAt, data }; call it after verifyWebhook |
isWebhookEventType(event, type: WebhookEventType): boolean | event.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_MS | 300000, 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
localStoragecart:getLocalCart,addToLocalCart,updateLocalCartItem,removeFromLocalCart,clearLocalCart,setLocalCartCustomer,setLocalCartShippingAddress,setLocalCartBillingAddress,setLocalCartCoupon,getLocalCartItemCount,removeLocalCartItemsByIndex. All@deprecatedand superseded by server-side session carts. They still exist for backward compatibility; thesmart*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/v1surface 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) andsetProductCustomizationFields. Refunds, cancellations, fulfilment and draft orders are dashboard (or MCP admin) operations today. - Methods that always fail or do nothing, by design.
getSyncConflictsandresolveSyncConflictthrow a 501 without a request;triggerSyncandgetSyncStatusanswer 501;syncCouponandpublishCouponare no-ops;getCouponPlatformCapabilitiesandgetMetafieldConflictsreturn an empty result;resolveMetafieldConflictandignoreMetafieldConflictanswer 404;setMetafieldPlatformsonly records a list. The full list with the alternative for each is the "Deprecated Methods" table in the package README. UsepublishProductToSalesChannel(instead ofpublishProduct) andupdateOrderwhere they cover the case.
Where to go next
- Core Integration Guide: build a storefront end to end
- Optional features: reviews, bundles, downloads, reservations
- Critical Rules: the mistakes that cost you a launch
- Authentication: picking a credential
- Error Catalog · Rate Limits · Pagination
- App SDK Reference: the other SDK, for marketplace apps
Region-Restricted Coupons
Limit a coupon to specific regions. An empty region list applies everywhere, and a non-empty list redeems only for buyers whose checkout region is in it.
App SDK Reference (@brainerce/app-sdk)
Manifest builder, connector / payment / shipping contracts, scopes, events and webhook verification for marketplace apps built on @brainerce/app-sdk.