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;
});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.
| 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.
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, i18n settings, SEO tokens | 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 |
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 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 / price / attribute / metafield filters and 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 |
getSearchSuggestions()throws instoreIdandapiKeymode. If you are building on a publicstoreId, filter withgetProducts({ 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):
| 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 |
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.
addToCart({ 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>> | Public review list, no login needed. Each review carries images; reviews with photos lead by default | 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.
Moderation is admin-side. See Part 2.
5. Buyer-input customization fields
| Method | Purpose | Modes |
|---|---|---|
uploadCustomizationFile(file: File | Blob): Promise<{ url: string; key: string }> | Upload an engraving photo or gallery image | 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
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.
| Method | Purpose | Modes |
|---|---|---|
smartAddToCart(item): Promise<Cart> | Add by productId (+ variantId, quantity, metadata, modifier selections, nestedByModifierId) | Local |
smartGetCart(options?: CartIncludeOptions): Promise<CartWithIncludes> | Fetch the current cart | Local |
smartUpdateCartItem(productId, quantity, variantId?): Promise<Cart> | Change a quantity | Local |
smartRemoveFromCart(productId, variantId?): Promise<Cart> | Remove a line | Local |
getSmartCartItemCount(): number | Badge count. Synchronous | Local |
syncCartOnLogin(): Promise<Cart> | Attach the guest cart to the customer after login | 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.
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 |
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 | 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; 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 |
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.
| Method | Purpose |
|---|---|
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
| 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 |
setBillingAddress(checkoutId, address: SetBillingAddressDto): Promise<Checkout> | Billing, with sameAsShipping support | Channel, Store, Admin |
completeCheckout(checkoutId): Promise<CompleteCheckoutResponse> | Finalize, returning { orderId } | 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.
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.
| 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 |
|---|---|---|
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 | 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.
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.
| Export | Purpose |
|---|---|
resolveRenderType(clientSdk, preferred?): RenderType | The platform's resolution rule, so the storefront can pick successUrl before the intent exists |
Two package-level guards for redirect flows:
| Export | Purpose |
|---|---|
isAllowedPaymentUrl(url, options?): boolean | Is this URL on the payment-redirect allowlist? |
safePaymentRedirect(url, options?): void | Navigate only if it is; blocks open-redirect abuse |
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().
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? }> | 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 |
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.
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.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 }> }> | Tag list for filters | Channel 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
| 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 }): Promise<TaxEstimateResponse> | Non-binding tax preview for PDP / cart | Channel, Store |
getStoreTaxClasses(): Promise<{ data: PublicTaxClass[] }> | Public tax classes | Channel, 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.
| 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, 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
| 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, 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 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 strings: basePrice, salePrice. There is no
compareAtPrice on this API.
Orders and shipments
| Method | Purpose |
|---|---|
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
| 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.
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(ids, status) | 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. 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
| 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 |
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.
| 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 |
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 / 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
| 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> | 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
| Method | Purpose |
|---|---|
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
| Method | Purpose |
|---|---|
triggerSync(platform?: ConnectorPlatform): Promise<SyncJob> | Kick off a sync |
getSyncStatus(jobId): Promise<SyncJob> | Poll the job |
See Connectors.
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 |
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 }.
| 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
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
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.
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.