Storefront SDK Reference (brainerce)

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

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

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

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

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

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


The three auth modes

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

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

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

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

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

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

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

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

Mode labels used on this page

Every table below has a Modes column:

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

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

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

Constructor options

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

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.


Conventions that apply everywhere

Methods are flat on the client. client.getProducts(), not client.products.list(). There are exactly three sub-namespaces (client.content, client.blog and client.contactForms), documented in their own sections below. Nothing else is nested.

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

Paginated reads return { data, meta }:

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

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

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

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


Session and mode introspection

All of these are Local, with no network call.

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

setCustomerToken() is a plain setter. Always follow it with await client.syncCartOnLogin(), or the shopper's guest cart is never attached to their account, and identity-keyed features (first-order discounts, per-customer usage caps, abandoned-cart recovery) misbehave.

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


Part 1: The mandatory storefront flow

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

1. Store info and tracking

MethodPurposeModes
getStoreInfo(): Promise<StoreInfo>Name, currency, language, i18n settings, SEO tokensChannel, Store
getStoreCapabilities(): Promise<StoreCapabilities>What the merchant configured on this channel: store, connection, featuresChannel
initTracking(tracking?: StoreTracking | null): voidWire GA4 / GTM / Meta / TikTok tags from store settings. SyncLocal
trackMarketingEvent(name, payload?): voidEmit a marketing event to the wired tags. SyncLocal
trackEvent(payload?: TrackEventPayload): Promise<void>First-party analytics beaconLocal
loadGoogleAnalytics(measurementId, options?): voidInject the GA script directly. SyncLocal

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

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

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

2. Browse, filter and search products

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

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

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

3. Product detail

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

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

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

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

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

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

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

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

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

Locale alternates for hreflang tags:

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

4. Product reviews

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

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

The last four review methods need setCustomerToken() first. Submission is rate limited to 3 per 60 s per IP. product.avgRating and product.reviewCount are denormalized onto every product read, so emit aggregateRating in your Product JSON-LD only when reviewCount > 0.

Moderation is admin-side. See Part 2.

5. Buyer-input customization fields

MethodPurposeModes
uploadCustomizationFile(file: File | Blob): Promise<{ url: string; key: string }>Upload an engraving photo or gallery imageChannel, 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 addToCart({ metadata }).

6. Cart

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

MethodPurposeModes
smartAddToCart(item): Promise<Cart>Add by productId (+ variantId, quantity, metadata, modifier selections, nestedByModifierId)Local
smartGetCart(options?: CartIncludeOptions): Promise<CartWithIncludes>Fetch the current cartLocal
smartUpdateCartItem(productId, quantity, variantId?): Promise<Cart>Change a quantityLocal
smartRemoveFromCart(productId, variantId?): Promise<Cart>Remove a lineLocal
getSmartCartItemCount(): numberBadge count. SynchronousLocal
syncCartOnLogin(): Promise<Cart>Attach the guest cart to the customer after loginLocal
onLogout(): voidDrop the cached customer cart. SyncLocal
onCheckoutComplete(): voidReset cart caching after a completed order. SyncLocal

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

Server cart (low level)

MethodPurposeModes
createCart(options?: CreateCartOptions): Promise<Cart>Create an empty cartChannel, Store, Admin
getCart(cartId, options?: CartIncludeOptions): Promise<CartWithIncludes>Fetch by id, optionally with includesChannel, Store, Admin
getCartBySession(sessionToken): Promise<Cart>Resolve a guest cart from its session tokenChannel, Store, Admin
addToCart(cartId, item: AddToCartDto): Promise<Cart>Add a lineChannel, Store, Admin
updateCartItem(cartId, itemId, data: UpdateCartItemDto): Promise<Cart>Change a lineChannel, Store, Admin
removeCartItem(cartId, itemId): Promise<Cart>Remove a lineChannel, Store, Admin
clearCart(cartId): Promise<Cart>Empty the cartChannel, Store, Admin
linkCart(cartId): Promise<Cart>Attach the cart to the logged-in customerChannel, Store
getMyCart(): Promise<Cart>The signed-in customer's cartStore only
recalculateCart(cartId): Promise<Cart>Re-price the cart against current product pricesChannel only
refreshCartSnapshots(cartId): Promise<Cart>Refresh per-item unitPrice snapshots to live pricesChannel only
getCartByCustomer(customerId): Promise<Cart>A given customer's cartAdmin only
mergeCarts(data: MergeCartsDto): Promise<Cart>Merge a guest cart into a customer cartAdmin only

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

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

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

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

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

7. Coupons

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

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

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

Discount display (rules, not codes):

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

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

8. Stock, availability and the reservation countdown

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

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. All Channel, Store.

MethodPurpose
getProductRecommendations(productId, type?: ProductRelationType): Promise<ProductRecommendationsResponse>Cross-sells, upsells and related products for a PDP
getCartRecommendations(cartId, limit?: number): Promise<CartRecommendationsResponse>Cross-sells based on what is already in the cart
getCartUpgrades(cartId): Promise<CartUpgradesResponse>Upsell swaps with a small price delta ("go large for $2")
getCartBundles(cartId): Promise<CartBundlesResponse>Bundle offers matching the current cart, with bundle pricing
addBundleToCart(cartId, bundleOfferId, variantSelections?: Record<string, string>): Promise<Cart>Accept a bundle, adding every offered product not yet in the cart at the bundle discount. Pass productId → variantId for offered products that have variants
removeBundleFromCart(cartId, bundleOfferId): Promise<Cart>Back the bundle out
getCheckoutBumps(checkoutId): Promise<CheckoutBumpsResponse>One-click add-ons for the checkout page
addOrderBump(cartId, bumpConfigId, variantId?): Promise<Cart>Accept a bump
removeOrderBump(cartId, bumpConfigId): Promise<Cart>Remove it

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

10. Checkout

MethodPurposeModes
createCheckout(data: CreateCheckoutDto): Promise<Checkout>Open a checkout from a cart (cartId, optional customerId, selectedItemIds, regionId)Channel, Store, Admin
getCheckout(checkoutId): Promise<Checkout>Re-read itChannel, Store, Admin
setCheckoutCustomer(checkoutId, data: SetCheckoutCustomerDto): Promise<Checkout>Attach customer details and order notesChannel, Store, Admin
setShippingAddress(checkoutId, address: SetShippingAddressDto): Promise<SetShippingAddressResponse>Step 1. Returns { checkout, rates }Channel, Store, Admin
getShippingRates(checkoutId): Promise<ShippingRate[]>Re-read the rates for the saved addressChannel, Store, Admin
selectShippingMethod(checkoutId, shippingRateId): Promise<Checkout>Pick a rateChannel, Store, Admin
setBillingAddress(checkoutId, address: SetBillingAddressDto): Promise<Checkout>Billing, with sameAsShipping supportChannel, Store, Admin
completeCheckout(checkoutId): Promise<CompleteCheckoutResponse>Finalize, returning { orderId }Channel, Store, Admin
deleteCheckout(checkoutId): Promise<{ success: boolean }>Abandon it and release the inventory reservationChannel, Store, Admin
getCheckoutCustomFields(checkoutId): Promise<CheckoutCustomFieldDefinition[]>Store-specific extra fields (tax ID, delivery notes…)Channel, Store
setCheckoutCustomFields(checkoutId, fields: Record<string, unknown>): Promise<Checkout>Submit their valuesChannel, Store
getShippingDestinations(): Promise<ShippingDestinations>Countries and regions the store ships toChannel only

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

A checkout expires 30 minutes after creation.

Gift cards (a tender, not a discount)

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

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

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

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

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

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

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

Address autocomplete

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

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

Pickup instead of delivery

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

11. Payment

MethodPurposeModes
getPaymentProviders(): Promise<PaymentProvidersConfig>Every installed provider plus the default, which is what you renderChannel only
createPaymentIntent(checkoutId, options?): Promise<PaymentIntent>Start the charge. Options: providerId, successUrl, cancelUrl, saveCard, preferredRenderTypeChannel only
getPaymentStatus(checkoutId): Promise<PaymentStatus>Poll the charge stateChannel only
confirmSdkPayment(checkoutId, providerResponseData?: Record<string, unknown>): Promise<{ confirmed: boolean }>Confirm a client-side SDK payment after the provider returnsChannel only
getPaymentConfig(): Promise<PaymentConfig>Deprecated single-provider shortcut; use getPaymentProviders()Channel only
confirmGrowPayment(checkoutId, confirmationNumber?, growResponseData?)Deprecated; use confirmSdkPayment()Local

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

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', 'sandbox' and 'embedded-fields'. 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 breaks on MAX and Takbull. The worked switch 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.

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 with resolveRenderType(provider.clientSdk, preferred) and pass the matching successUrl; then branch on the renderType that came back, never on what you asked for.

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

Two package-level guards for redirect flows:

ExportPurpose
isAllowedPaymentUrl(url, options?): booleanIs this URL on the payment-redirect allowlist?
safePaymentRedirect(url, options?): voidNavigate only if it is; blocks open-redirect abuse

12. Order confirmation

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

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

Guest checkout

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

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

Downloads for digital products

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

13. Customer authentication

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

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

After a successful login or registration: setCustomerToken(auth.token) then await syncCartOnLogin().

OAuth (social login)

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

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

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

14. Customer account

MethodPurposeModes
getMyProfile(): Promise<CustomerProfile>The signed-in customer's profile, including birthMonth / birthDayChannel, Store
updateMyProfile(data): Promise<CustomerProfile>firstName, lastName, phone, acceptsMarketing, birthMonth, birthDayChannel, Store
getCustomerProfile(): Promise<{ id, email, firstName?, lastName?, phone?, emailVerified, role? }>Lighter profile read; role is the merchant's free-form segmentChannel, Store
getMyOrders(params?: { page?, limit? }): Promise<PaginatedResponse<Order>>Paginated order historyChannel, Store
getCheckoutPrefillData(): Promise<CheckoutPrefillData>Pre-fill the checkout form for a returning customerChannel, Store
getMyAddresses(): Promise<CustomerAddress[]>Saved addressesChannel, Store
addMyAddress(address: CreateAddressDto): Promise<CustomerAddress>Add oneChannel, Store
updateMyAddress(addressId, data: UpdateAddressDto): Promise<CustomerAddress>Edit oneChannel, Store
deleteMyAddress(addressId): Promise<void>Remove oneChannel, Store
getMySavedPaymentMethods(): Promise<StorefrontSavedPaymentMethod[]>Vaulted cards for this customerChannel, Store

Logout is clearCustomerToken() plus onLogout().

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

Loyalty and memberships

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

15. Site chrome from merchant content

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

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

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

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

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

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

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

16. Blog and SEO

client.blog is the second namespace:

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

Post bodies are raw HTML, so sanitize before rendering.

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

Sitemap, redirects and structured data

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

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

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

Never emit buildProductJsonLd on a listing page.

17. Categories, brands and tags

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

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

18. Regions, currency and tax

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

A store has one base currency. Passing regionId to a product read adds display-only displayPrice / displaySalePrice / displayCurrency. Passing regionId to createCheckout is different: a presentment-enabled region charges in the region currency and the checkout carries a presentment overlay. See Regions and FX analytics.

19. Contact forms

client.contactForms is the third namespace.

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

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

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

20. Marketing signup

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

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

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

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

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

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

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

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

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

21. Back-in-stock alerts

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

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

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

Four conditions, all of them:

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

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

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

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

Sending is not immediate. Availability is total - reserved, 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.


Part 2: Admin reference (apiKey)

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

Products, variants and media

MethodPurpose
createProduct(data: CreateProductDto): Promise<Product>Create one product
updateProduct(productId, data: UpdateProductDto): Promise<Product>Update it
deleteProduct(productId, options?: { platforms?: string[] }): Promise<DeleteProductResponse>Delete, optionally on connected platforms
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>Replace the whole variant matrix
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<void>Set product-level stock
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
deleteMedia(id): Promise<{ success: boolean }>Delete

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

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

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

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

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

Prices in CreateProductDto are strings: basePrice, salePrice. There is no compareAtPrice on this API.

Orders and shipments

MethodPurpose
getOrders(params?: OrderQueryParams): Promise<PaginatedResponse<Order>>List orders
getOrder(orderId): Promise<Order>One order
updateOrder(orderId, data: UpdateOrderDto): Promise<Order>Update an order
getOrderShippingRates(orderId): Promise<…>Live carrier rates: id, name, price (string), currency, estimatedDays, carrier, service
createShippingLabel(orderId, data): Promise<…>Buy a label. { rateId, parcel?, labelFormat?: 'PDF' | 'PNG' | 'ZPL' | 'EPL', customsContentsType? }{ shipmentId, labelUrl, trackingNumber, carrier, labelFormat? }
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

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

Customers

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

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

Gift cards (administration)

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

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

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

The code is returned exactly once

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

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

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

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

expiresAt is optional, and write-once

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

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

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

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

Re-issue is not a resend

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

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

A note is mandatory, and there is no delete

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

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

Ask for the least scope you need

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

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

Coupons

MethodPurpose
getCoupons(params?: CouponQueryParams): Promise<PaginatedResponse<Coupon>>List
getCoupon(couponId): Promise<Coupon>Read
createCoupon(data: CreateCouponDto): Promise<CouponCreateResponse>Create. The response carries validation warnings
updateCoupon(couponId, data: UpdateCouponDto): Promise<Coupon>Update
deleteCoupon(couponId): Promise<void>Delete
syncCoupon(couponId): Promise<SyncJob>Push to connected platforms
publishCoupon(couponId, platforms: ConnectorPlatform[]): Promise<SyncJob>Publish to specific platforms
getCouponPlatformCapabilities(): Promise<Record<string, PlatformCouponCapabilities>>Per-platform coupon capabilities. Returns {} today — the data moved into the connector apps and is not re-exposed yet, so treat a missing platform key as "unknown", not as "unsupported"

See Region coupons for multi-currency rules.

Reviews moderation

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

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

Taxonomy: categories, brands, tags, attributes

MethodPurpose
listCategories(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Category>>List
getCategory(categoryId): Promise<Category>Read
createCategory(data: CreateCategoryInput): Promise<Category>Create
updateCategory(categoryId, data: UpdateCategoryInput): Promise<Category>Update
deleteCategory(categoryId): Promise<void>Delete
listBrands(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Brand>>List
getBrand(brandId): Promise<Brand>Read
createBrand(data: CreateBrandInput): Promise<Brand>Create
updateBrand(brandId, data: UpdateBrandInput): Promise<Brand>Update
deleteBrand(brandId): Promise<void>Delete
listTags(params?: TaxonomyQueryParams): Promise<PaginatedResponse<Tag>>List
getTag(tagId): Promise<Tag>Read
createTag(data: CreateTagInput): Promise<Tag>Create
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

Modifier groups

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

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

See Modifiers.

Shipping zones and rates

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

Regions and regional prices

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

Tax classes and rates

MethodPurpose
getTaxClasses(): Promise<{ data: TaxClass[] }>List
getTaxClass(id): Promise<TaxClass & { dependents: { productCount, variantCount, categoryCount, taxRateCount } }>Read with dependent counts
createTaxClass(data: CreateTaxClassDto): Promise<TaxClass>Create
updateTaxClass(id, data: UpdateTaxClassDto): Promise<TaxClass>Update
deleteTaxClass(id): Promise<void>Delete
setDefaultTaxClass(id): Promise<TaxClass>Make it the default
assignTaxClass(id, data: AssignTaxClassDto): Promise<{ updated: number }>Bulk-assign to products / categories
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

See Tax classes.

Metafields

MethodPurpose
getMetafieldDefinitions(): Promise<MetafieldDefinition[]>List definitions
getMetafieldDefinition(definitionId): Promise<MetafieldDefinition>Read one
createMetafieldDefinition(data: CreateMetafieldDefinitionInput): Promise<MetafieldDefinition>Create
updateMetafieldDefinition(definitionId, data: UpdateMetafieldDefinitionInput): Promise<MetafieldDefinition>Update
deleteMetafieldDefinition(definitionId): Promise<void>Delete
setMetafieldPlatforms(definitionId, data: SetMetafieldPlatformsInput): Promise<MetafieldDefinition>Map it onto connector platforms
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[]>Sync conflicts
resolveMetafieldConflict(conflictId, data: ResolveMetafieldConflictInput): Promise<MetafieldConflict>Resolve one
ignoreMetafieldConflict(conflictId): Promise<MetafieldConflict>Ignore one

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

Publishing to sales channels

MethodPurpose
publishProductToSalesChannel(productId, salesChannelId): Promise<{ success: boolean }>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.

Connector sync

MethodPurpose
triggerSync(platform?: ConnectorPlatform): Promise<SyncJob>Kick off a sync
getSyncStatus(jobId): Promise<SyncJob>Poll the job

See Connectors.

Email settings and templates

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

OAuth provider configuration

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

Storefront bot

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

Account-level team (deprecated)

getTeamMembers(), getTeamInvitations(), inviteTeamMember(), resendTeamInvitation(), revokeTeamInvitation(), updateTeamMemberRole() and removeTeamMember() are marked @deprecated in the SDK, and they reach /api/v1/team/…, the account team, not any single store's team.

These seven do not work with an API key, and never have. team:read and team:write are not API-key scopes, so the request is refused before the handler runs; past that guard the routes have no signed-in user to resolve and return 403. They were removed from the public API reference for that reason. The store-level equivalents (getStoreTeam, inviteStoreMember, updateStoreMember, removeStoreMember, resendStoreInvitation, revokeStoreInvitation) are not a workaround either: they sit behind a dashboard-only guard that rejects API-key callers by design, so an API key gets a 403 there too. Fixing the path would not help.

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,
  // i18n
  getDirectionForLocale,
  RTL_LOCALES,
} from 'brainerce';

Webhooks

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

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

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

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

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

Date and time availability

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

Development guards

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

Types

Most types named on this page are exported from brainerce. Import those rather than hand-writing your own copies, because the shipped .d.ts is the source of truth and it moves.

Two things to know before you write an import.

1. Admin input types are exported as …Input, not …Dto. The *Dto names are the SDK's internal ones and are not on the package surface, so import type { CreateTaxRateDto } from 'brainerce' fails to compile. The public names end in Input: CreateTaxRateInput, CreateShippingRateInput, UpdateCategoryInput, and so on. This page uses the public names. TypeScript will suggest the right one if you reach for the old spelling.

2. Some types this page names are not exported yet. These are declared inside the SDK but are not re-exported from the package entry point, so they cannot be imported today even though the page names them so you can see the shape you are passing or receiving:

Region, PublicRegion, PublicRegionDetail, AutoRegionResponse, CreateRegionDto, UpdateRegionDto, RegionPriceEntry, UpsertRegionPricesResult, TaxClass, PublicTaxClass, CreateTaxClassDto, UpdateTaxClassDto, AssignTaxClassDto, TaxEstimateResponse, MediaAsset, ListMediaParams, UpdateMediaAssetInput, BotSettings, BotSettingsConfigResponse, UpdateBotSettingsDto, BotConversationsPage, BotConversationDetail, SummarizeBotConversationResult, LoyaltyMembershipPlan, LoyaltyRewardRecommendation, PaidMembershipInfo, SavedPaymentMethodSummary, StorefrontSavedPaymentMethod, CreateCartOptions and TrackEventPayload.

Until they are exported, let TypeScript infer the type from the call (const zone = await client.getRegions()) instead of annotating it by hand. Do not copy the shape into your own interface. An inferred type follows the SDK when it changes; a hand-written copy silently drifts.


Not on this page

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

  • The legacy localStorage cart: getLocalCart, addToLocalCart, updateLocalCartItem, removeFromLocalCart, clearLocalCart, setLocalCartCustomer, setLocalCartShippingAddress, setLocalCartBillingAddress, setLocalCartCoupon, getLocalCartItemCount, removeLocalCartItemsByIndex. All @deprecated and superseded by server-side session carts. They still exist for backward compatibility; the smart* family is the supported surface.
  • Methods with no reachable endpoint. A number of client methods target paths that the public API does not expose. Rather than document a call that returns 404, they are left out. If you need one of those operations today, use the dashboard.

Where to go next

On this page

The three auth modesMode labels used on this pageConstructor optionsConventions that apply everywhereSession and mode introspectionPart 1: The mandatory storefront flow1. Store info and tracking2. Browse, filter and search products3. Product detailKits (type: 'KIT') on a product page4. Product reviews5. Buyer-input customization fields6. CartServer cart (low level)7. Coupons8. Stock, availability and the reservation countdown9. Upsells, bundles, order bumps and recommendations10. CheckoutGift cards (a tender, not a discount)Address autocompletePickup instead of delivery11. Payment12. Order confirmationGuest checkoutDownloads for digital products13. Customer authenticationOAuth (social login)14. Customer accountLoyalty and memberships15. Site chrome from merchant content16. Blog and SEOSitemap, redirects and structured data17. Categories, brands and tags18. Regions, currency and tax19. Contact forms20. Marketing signup21. Back-in-stock alertsPart 2: Admin reference (apiKey)Products, variants and mediaOrders and shipmentsCustomersGift cards (administration)The code is returned exactly onceexpiresAt is optional, and write-onceRe-issue is not a resendA note is mandatory, and there is no deleteAsk for the least scope you needCouponsReviews moderationTaxonomy: categories, brands, tags, attributesModifier groupsShipping zones and ratesRegions and regional pricesTax classes and ratesMetafieldsPublishing to sales channelsConnector syncEmail settings and templatesOAuth provider configurationStorefront botAccount-level team (deprecated)Part 3: Package-level exportsWebhooksDate and time availabilityDevelopment guardsTypesNot on this pageWhere to go next