Optional Features

Customer accounts, social login, promotions, upsells, downloads, loyalty & rewards, gift cards, AI chat widget (Storefront Bot). Read after Core.

This is Part 2 of the integration guide.

  • Part 1 (REQUIRED): Core Integration. Core storefront: products, cart, checkout, payment, orders
  • Part 2 (OPTIONAL): This file. Customer accounts, social login, promotions, upsells, downloads
  • Part 3 (REFERENCE): Rules & Reference. Validation rules, error codes, edge cases, decision trees, common mistakes

Read Part 1 first. It contains the API base URL, helper functions, and storeConfig object used throughout this file.


Task 8: Customer accounts

Step 8.1: Register

POST /customers/register
{
  "email": "[email protected]",
  "password": "securePassword123",
  "firstName": "John",
  "lastName": "Doe",
  "birthMonth": 4,
  "birthDay": 17
}

birthMonth (1-12) and birthDay (1-31) are optional, carry no year, and must be sent together or not at all. They power the birthday gift email (Task 16.4). Make them required inputs on this form only when connection.requireBirthday is true in the capabilities response (also on getStoreInfo().requireBirthday, where an absent field means false), and note that flag is enforced on this route only: vc_* sales-channel password registration. It is not enforced on a storeId-connected storefront, on OAuth sign-in, or on guest checkout.

Response:

{
  "customer": {
    "id": "cust_abc123",
    "email": "[email protected]",
    "firstName": "John",
    "lastName": "Doe"
  },
  "token": "eyJhbGciOi...",
  "requiresVerification": true
}

Do not put result.token in localStorage. customerToken is a bearer credential: any XSS anywhere on the page reads it and the attacker is that customer until it expires. Send it to your own server instead and have that server set an HttpOnly cookie the page's JavaScript cannot read — the Backend-For-Frontend pattern. salesChannelId, brainerce_session and brainerce_cart_id are safe to keep in localStorage; the customer token is not, and it is the only value on this page that is not.

The raw localStorage calls shown in this task are the browser-only shape, for orientation. They make the request/response flow readable in one snippet. Do not ship them.

If you are using the SDK, it never writes the token to storage itself: loginCustomer() returns it and setCustomerToken(auth.token) holds it in memory for the session. Front it with a BFF and you skip even that — construct the client with proxyMode: true and a same-origin baseUrl, and the SDK stops attaching customer Authorization headers because your proxy is authenticating from the HttpOnly cookie instead.

If requiresVerification → show 6-digit code input (step 8.4).

Step 8.2: Login

POST /customers/login
{
  "email": "[email protected]",
  "password": "securePassword123"
}

Same response. Save token the same way.

Step 8.3: Merge guest cart after login

Do not skip this, and run it after every sign-in path: password login, email verification, and OAuth alike. Storing the JWT authenticates your requests but does not attach the cart the shopper filled before signing in. A cart that never gets claimed has no buyer identity, and everything keyed on identity then degrades silently rather than erroring:

  • customer_first_order discount rules keep applying to returning customers.
  • Per-customer usage caps are not enforced at cart time.
  • Abandoned-cart recovery cannot tell who to email.

Using the SDK, this is one call:

client.setCustomerToken(result.token);
await client.syncCartOnLogin(); // links or merges the guest cart into the account

Against the raw API:

const sessionToken = localStorage.getItem('brainerce_session');
if (sessionToken) {
  try {
    const cart = await brainerceAPI('POST', '/cart/merge', {
      sourceSessionToken: sessionToken,
    });
    localStorage.setItem('brainerce_cart_id', cart.id);
    if (cart.sessionToken) localStorage.setItem('brainerce_session', cart.sessionToken);
  } catch {
    /* guest cart was empty or expired — ignore */
  }
}

Requires Authorization: Bearer {token} header.

Step 8.4: Email verification (if required)

Verify:

POST /customers/verify-email
{ "code": "123456" }

Requires auth header. Code is 6 digits from email.

Resend:

POST /customers/resend-verification

Requires auth header. Rate limited: 3 per hour.

Step 8.5: Password reset

Request:

POST /customers/forgot-password
{
  "email": "[email protected]"
}

The reset URL embedded in the outgoing email is server-derived from the sales-channel's registered domain. Clients do not, and cannot, supply it: resetUrl was removed from the request body deliberately, because a caller-supplied URL let an attacker have a Brainerce-domained email deliver a phishing link.

Send email and nothing else. A resetUrl field in the body is rejected, not ignored. The API runs forbidNonWhitelisted, so the request fails with HTTP 400 and property resetUrl should not exist. SDK versions before 2.0.0 attach it automatically in the browser, so upgrade the SDK if password reset is returning 400.

Complete (on reset page):

POST /customers/reset-password
{
  "token": "from-url-query-param",
  "newPassword": "newPassword456"
}

Step 8.6: Logout

// The customer token is NOT in localStorage and must never be put there (see
// the warning in Step 8.1). Clear it wherever you actually hold it:
//   - held in memory by the SDK -> clearCustomerToken()
//   - held by a BFF proxy       -> call your logout route, which clears the
//                                  HttpOnly cookie; the browser has no copy
client.clearCustomerToken();

// Drop the cart keys so the next person on this browser starts a fresh guest
// cart instead of inheriting the one that was just signed out of. These two
// are cart references, not credentials.
localStorage.removeItem('brainerce_session');
localStorage.removeItem('brainerce_cart_id');

Task 9: Customer profile & order history

All require Authorization: Bearer {token}.

Step 9.1: Get profile

GET /customers/me

The response carries birthMonth (1-12) and birthDay (1-31), both absent until the customer sets them (they are returned as a pair or not at all). Seed your profile form from them: this read is the only way to tell "already filled in" from "never asked".

The response includes a role field, a free-form segment the merchant sets from the dashboard or admin API (e.g. "wholesale", "vip", "ambassador"). It's read-only from the storefront (never send it in Step 9.2's update body, where it's silently ignored). Use it to gate custom, per-segment features in your own code:

if (profile.role === 'wholesale') {
  // show a wholesale price list / bulk-order UI
}

Step 9.2: Update profile

PATCH /customers/me
{ "firstName": "Jonathan", "phone": "0509876543", "birthMonth": 4, "birthDay": 17 }

Editable fields: firstName, lastName, phone, acceptsMarketing, birthMonth, birthDay. Send the birthday pair together or not at all; February 29 is valid.

Step 9.3: Saved addresses

ActionMethodEndpoint
ListGET/customers/me/addresses
AddPOST/customers/me/addresses
UpdatePATCH/customers/me/addresses/{addressId}
DeleteDELETE/customers/me/addresses/{addressId}

Address fields: firstName, lastName, line1, line2, city, region, postalCode, country, phone, isDefault

Step 9.4: Order history

GET /customers/me/orders?page=1&limit=10

Returns paginated order list (same structure as Task 6 in Core Integration).

Each order carries notes, the shopper's own order note from checkout, echoed back read-only. Display it on the order-detail view when present (e.g. under a "Your order note" label). It cannot be edited after purchase.

Step 9.5: Pre-fill checkout

GET /customers/me/checkout-prefill

Returns customer info + default address. Use to auto-fill the checkout form. The customer object carries birthMonth and birthDay too, so a checkout that asks for a birthday can pre-fill it.

Step 9.6: Display buyer customizations on past orders

If the store uses product customization fields (see Core Integration Step 2.8), each order item captures a per-item snapshot of the buyer's submitted values at checkout time. Display these on order-detail pages so buyers see what they ordered.

Each order.items[i] includes an optional customizations map:

{
  "customizations": {
    "engraving_text": { "label": "Engraving", "value": "Happy Birthday!", "type": "TEXT" },
    "upload_photo": {
      "label": "Upload Photo",
      "value": "https://assets.brainerce.com/.../abc.jpg",
      "type": "IMAGE"
    },
    "gift_wrap": { "label": "Gift wrap", "value": "yes", "type": "BOOLEAN" }
  }
}
Field typeValue shapeRender as
TEXT, TEXTAREA, URLstringplain text
NUMBERstringified numberplain text
BOOLEAN"yes" / "no"checkmark / ✗
SELECTstringthe chosen option
MULTI_SELECTstring[]comma-separated
IMAGEasset URL (string)thumbnail linking to the full-size asset
GALLERYstring[] of URLsgrid of thumbnails
COLORhex stringcolored swatch
DATE, DATETIMEISO-8601 stringlocalized date

Notes:

  • The customizations map is a snapshot. Even if the merchant later renames or deletes the underlying MetafieldDefinition, the order keeps the original label and value intact.
  • Fields flagged as appliesToAllProducts: true on their MetafieldDefinition still appear in per-item customizations on every order item that had values submitted for them, so no special handling is needed on display.
  • The type value mirrors the MetafieldType enum (TEXT, TEXTAREA, NUMBER, BOOLEAN, DATE, DATETIME, URL, COLOR, SELECT, MULTI_SELECT, IMAGE, GALLERY, …). Use it to choose a renderer; treat unknown types as plain text.

Step 9.7: Show shipping address and tracking on past orders

Every Order returned by GET /customers/me/orders already carries the buyer's shipping snapshot and any tracking info the merchant captured. Surface both, because it is the #1 thing buyers look for after purchase.

Relevant fields on Order:

FieldShapeNotes
shippingAddress{ firstName, lastName, company?, line1, line2?, city, region, postalCode, country, phone? }A snapshot at checkout; it won't change afterwards.
trackingNumberstring | nullCarrier tracking number.
trackingUrlstring | nullDirect tracking URL. Render as an <a> when present.
carrierstring | nullCarrier name (e.g., "UPS", "DHL").
shippedAtISO-8601 string | nullWhen the order left the warehouse.
deliveredAtISO-8601 string | nullWhen it arrived.
{
  order.shippingAddress && (
    <section>
      <h3>{t('account.shippingAddress')}</h3>
      <p>
        {order.shippingAddress.firstName} {order.shippingAddress.lastName}
        <br />
        {order.shippingAddress.line1}
        {order.shippingAddress.line2 ? `, ${order.shippingAddress.line2}` : null}
        <br />
        {order.shippingAddress.city}, {order.shippingAddress.region}{' '}
        {order.shippingAddress.postalCode}
        <br />
        {order.shippingAddress.country}
      </p>
    </section>
  );
}

{
  order.trackingNumber && (
    <section>
      <h3>{t('account.tracking')}</h3>
      <p>
        {order.carrier} · {order.trackingNumber}
      </p>
      {order.shippedAt && <p>{t('account.shippedOn', { date: formatDate(order.shippedAt) })}</p>}
      {order.deliveredAt && (
        <p>{t('account.deliveredOn', { date: formatDate(order.deliveredAt) })}</p>
      )}
      {order.trackingUrl && (
        <a href={order.trackingUrl} target="_blank" rel="noopener noreferrer">
          {t('account.trackOrder')}
        </a>
      )}
    </section>
  );
}

Render nothing when the data is absent, and do not show empty "no tracking yet" placeholders.

Step 9.8: Show payment status

Order also includes a financial status that you should map to a colored badge.

FieldValues
paymentMethode.g., "card", "paypal", "bank_transfer", "cash_on_delivery"
financialStatus"pending" / "authorized" / "partially_paid" / "paid" / "partially_refunded" / "refunded" / "voided". Handle all seven; "authorized" and "partially_paid" arrive from connected platforms
fulfillmentStatus"unfulfilled" / "partial" / "fulfilled", useful for item-level nuance

Suggested UI mapping:

financialStatusBadge color
paidgreen
pendingamber
refundedneutral
partially_refundedamber
voidedred
{
  (order.paymentMethod || order.financialStatus) && (
    <section>
      <h3>{t('account.paymentMethod')}</h3>
      <span>{capitalize(order.paymentMethod?.replace(/_/g, ' '))}</span>
      {order.financialStatus && (
        <span className={badgeClassFor(order.financialStatus)}>
          {t(`account.${order.financialStatus}`)}
        </span>
      )}
    </section>
  );
}

Step 9.9: Show the order status timeline

Order.statusHistory is an array of status transitions in chronological order. Render each as a dot + localized status + localized timestamp.

interface OrderStatusChange {
  status: OrderStatus; // 'PENDING' | 'PROCESSING' | 'SHIPPED' | 'DELIVERED' | 'COMPLETED' | ...
  at: string; // ISO-8601
  note?: string | null;
}
{
  order.statusHistory?.length ? (
    <section>
      <h3>{t('account.statusTimeline')}</h3>
      <ol>
        {order.statusHistory.map((entry, i) => (
          <li key={i}>
            <span aria-hidden></span>
            <span>{t(`account.status${capitalize(entry.status.toLowerCase())}`)}</span>
            <time dateTime={entry.at}>{formatDateTime(entry.at)}</time>
          </li>
        ))}
      </ol>
    </section>
  ) : null;
}

The existing statusPending, statusProcessing, statusShipped, etc. i18n keys (already present in the scaffolded template under messages/{en,he}.json) map 1:1 to OrderStatus values.


Task 10: Social login (OAuth)

Step 10.1: Show buttons

From capabilities: storeConfig.oauthProviders → show button per enabled provider.

Step 10.2: Start OAuth

GET /oauth/{provider}/authorize?redirectUrl=https://your-site.com/auth/callback

Provider: google, facebook, or github (lowercase).

redirectUrl is not the provider callback URL. They are two different addresses. redirectUrl is a Brainerce parameter naming the page on your site the shopper lands on once sign-in is finished. The provider never sees it; it is stored server-side against the OAuth state. The address registered with Google/Facebook/GitHub is Brainerce's own, fixed for every store: https://api.brainerce.com/api/oauth/customer/callback A store owner adds that one to their provider console. See API keys and sign-in providers. Nothing in your storefront code sets it, and it is never your own domain.

redirectUrl is validated server-side against the sales channel's trusted origins, and the request fails with a 400 before the shopper ever reaches the provider if it does not match.

In vibe-coded mode (vc_*) pass an absolute URL on your registered domain or one of its allowedOrigins. TEST-mode channels also accept any localhost port. A relative path such as /auth/callback works too; it is resolved against the channel's domain on the way back, so the channel needs one registered.

Social login requires a salesChannelId connection. In storefront mode (storeId) no channel is bound to the request: an absolute URL has no trusted-origin list to match against, and a relative path has no origin to resolve against when the provider sends the shopper back. Use a vc_* connection for OAuth.

Response: { "authorizationUrl": "https://...", "state": "...", "provider": "google" }

Redirect user to authorizationUrl.

Step 10.3: Handle callback

The backend's public OAuth callback handles the provider code exchange for you and redirects to the redirectUrl you supplied in step 10.2. Both outcomes land there, success and failure alike, so /auth/callback must handle each.

Success:

?oauth_success=true&auth_code=<single-use-code>&is_new=<true|false>

Failure (RFC 6749 §4.1.2.1 shape):

?oauth_error=<stable_snake_case_code>&error_description=<English text>

Switch on oauth_error to render localized copy. error_description is a developer aid whose wording may change, so do not show it to shoppers. The list is open (provider codes pass through), so always handle the default case:

oauth_errorWhat happenedWhat to do
access_deniedShopper declined consent at the providerReturn to login, no error styling needed
state_expiredThe sign-in sat unfinished past its 10-minute windowAsk them to try again
state_already_used / invalid_stateReplayed or unrecognised callbackAsk them to start sign-in again
link_blocked_unverified_password_accountAn unverified password account exists and the provider would not vouch eitherSend them to email verification; a retry will not help
provider_already_linkedThis customer already has a different account linked for this providerPoint them at profile settings to unlink first
oauth_account_linked_to_another_customerThat provider account belongs to a different customerOffer password sign-in instead
provider_unsupported / provider_disabledProvider is not implemented, or the merchant turned it offHide that button
server_errorUnexpected platform failureGeneric retry message

On success, read auth_code from the URL and exchange it for the JWT (single-use, 2-minute TTL):

POST /api/oauth/customer/exchange
Content-Type: application/json

{ "code": "<auth_code from URL>" }

Response:

{
  "customer": { "id": "cust_abc", "email": "[email protected]" },
  "token": "eyJ...",
  "expiresAt": "2026-05-25T12:34:56.000Z",
  "isNewCustomer": true,
  "linkedToExisting": false,
  "provider": "GOOGLE",
  "redirectUrl": "/"
}

Save token, merge cart (step 8.3, client.syncCartOnLogin()), redirect to redirectUrl (or homepage). The merge is the step most OAuth implementations forget; without it the shopper is logged in but their cart is still anonymous.

The legacy redirect format included ?token=<JWT>&customer_id=... directly in the URL. Those params are still emitted for backward compatibility but will be removed in the next major release. JWTs in URLs persist in browser history, CDN logs, and Referer headers, so migrate to the auth_code exchange flow now.

Step 10.4: Manage linked accounts

ActionMethodEndpointAuth
List linkedGET/customers/me/oauth-connectionsYes
Link newPOST/oauth/{provider}/linkYes
UnlinkDELETE/oauth/{provider}/linkYes

Task 11: Discount banners & promotions

Discount banners (site-wide)

GET /discount-banners

Returns active promotions. Display at top of page.

Product discount badge

GET /products/{productId}/discount-badge

Returns badge info for a product. Show on product card (e.g., "20% OFF").

Cart nudges

Returned automatically in cart response as cart.nudges. Display above/below cart items. Example: "Add ₪30.10 more for free shipping!"


Task 12: Upsells, bundles, bumps and upgrades

Instead of calling each endpoint separately, fetch everything in a single request:

GET /cart/{cartId}?include=recommendations,upgrades,bundles

Returns the cart with recommendations, upgrades, and bundles fields included. With the SDK:

const cart = await client.getCart(cartId, {
  include: ['recommendations', 'upgrades', 'bundles'],
});
// cart.recommendations — cross-sell recommendations
// cart.upgrades — upgrade suggestions per cart item
// cart.bundles — bundle offers

// Also works with smartGetCart:
const cart = await client.smartGetCart({
  include: ['recommendations', 'upgrades', 'bundles'],
});

The individual endpoints below still work for targeted refreshes.

Cart bundles

GET /cart/{cartId}/bundles

Returns N-product bundle offers triggered by items in the cart. Each bundle has:

  • triggerProductId (= productIds[0]): already in cart, activates the offer
  • productIds: string[]: full bundle composition (length ≥ 2)
  • offeredProducts[]: bundle entries the customer hasn't added yet, each with originalPrice + discountedPrice
  • totalOriginalPrice / totalDiscountedPrice: sum across offeredProducts

For a VARIABLE offered product, originalPrice is the merchant-pinned variant's price (pinnedVariant is set) or the cheapest variant ("from {min}"), never a stale 0 parent price. When the merchant did not pin a variant, the entry carries requiresVariantSelection: true and a variants[] list so you can show a picker.

Accept a bundle: POST /cart/{cartId}/bundle with { "bundleOfferId": "bundle_abc", "variantSelections"?: { "<productId>": "<variantId>" } }. Adds every offered product not yet in cart with the bundle discount applied. A merchant-pinned variant is used automatically; you only need variantSelections for offered products where requiresVariantSelection is true.

Remove an accepted bundle: DELETE /cart/{cartId}/bundle/{bundleOfferId} removes every cart item linked to that bundle offer.

The bundle discount is conditional, and the server re-checks it. A bundle line keeps its discount only while the offer's trigger product (productIds[0]) is still in the cart and the offer itself still resolves. Delete the trigger line, and the offered product's promoDiscountAmount is released to 0 on the next cart mutation and again when the checkout is created — so never treat an accepted bundle's discount as locked in, and re-read the cart totals after every mutation rather than tracking them client-side. The same release happens if the merchant deletes the bundle offer or re-points it at a different trigger. Line metadata you send has no bearing on this: the discount's justification is resolved server-side from the offer, not from anything the client writes.

A promo discount is per unit and is re-checked against the line's current quantity. promoDiscountAmount on a cart line is an absolute, whole-line figure, not a per-unit one. The server holds it to perUnit x quantity, where perUnit is recomputed from the offer or order-bump configuration, so reducing a line's quantity reduces its discount proportionally. The clamp only ever lowers: raising a line's quantity by hand does not extend the discount to units no offer was accepted for, and raising the configured discount does not retroactively enlarge a discount already accepted. Deleting the offer or bump config, or clearing its discount, releases the line's discount to 0 on the next cart mutation. Treat promoDiscountAmount as server-owned output: read it back after every mutation, never carry it client-side.

Cart upgrades

GET /cart/{cartId}/upgrades

Suggests premium variants with small price increase.

Checkout order bumps

GET /checkout/{checkoutId}/bumps

Returns one-click add-ons. Display during checkout.

Add bump: POST /cart/{cartId}/bump with { "bumpConfigId": "bump_abc" } Remove bump: DELETE /cart/{cartId}/bump/{bumpConfigId}


Task 13: Inventory countdown & reservation

Check availability

POST /availability
{ "productIds": ["prod_abc", "prod_def"] }

Response per product: { trackingMode, total, reserved, available, canPurchase, lowStock }

Reservation countdown

After creating checkout, the response may include:

{
  "reservation": {
    "hasReservation": true,
    "expiresAt": "2026-04-09T12:15:00.000Z",
    "remainingSeconds": 900,
    "countdownMessage": "Items reserved for 15 minutes"
  }
}

Show countdown timer. When expired → prompt to refresh.

Extend: POST /reservation/extend with { "checkoutId": "chk_abc" } (max 60 min) Release: POST /reservation/release with { "checkoutId": "chk_abc" }


Task 14: Digital product downloads

If storeConfig.hasDownloads is true.

Guest downloads

POST /orders/guest/downloads
{ "email": "[email protected]", "orderNumber": "ORD-00042" }

Customer downloads

GET /customers/me/orders/{orderId}/downloads

Requires auth header.

Response

[
  {
    "fileId": "file_001",
    "productName": "JavaScript Mastery E-Book",
    "fileName": "javascript-mastery.pdf",
    "url": "https://s3.amazonaws.com/...",
    "downloadsUsed": 0,
    "downloadLimit": 5,
    "expiresAt": "2026-05-09T12:00:00.000Z"
  }
]
ConditionDisplay
downloadsUsed < downloadLimitDownload button
downloadsUsed >= downloadLimit"Download limit reached"
expiresAt in the past"Download expired"
downloadLimit is nullUnlimited
expiresAt is nullNever expires

Task 15: Loyalty & rewards

Let logged-in customers earn points on purchases and redeem them for a one-time discount coupon at checkout.

Storefront + vibe-coded modes. Loyalty works with a client created either as new BrainerceClient({ storeId }) (storefront mode) or new BrainerceClient({ salesChannelId: 'vc_*' }) (vibe-coded mode). Both need a logged-in customerToken. The SDK methods pick the right route automatically based on how the client was constructed.

Points are earned automatically by the platform when an order is paid (there is no earn call from the storefront). Your storefront's job is only to show the balance and rewards and to redeem.

Step 15.1: Show the customer's status

// requires a logged-in customer: client.setCustomerToken(auth.token)
const status = await client.getLoyaltyStatus();
// {
//   enrolled, pointsBalance, lifetimeEarned,
//   pendingPoints,          // earned but still inside the return window, NOT in pointsBalance
//   pendingPointsConfirmAt, // ISO date those pending points become spendable, or null
//   program: { pointsName, currencyRatio, pendingDays, status } | null,
//   tier: { id, name, level, pointsMultiplier } | null,
//   nextTier: { id, name, level, pointsMultiplier, qualificationType, qualificationThreshold } | null,
//   progressToNextTier, // 0..1
//   pointsToNextTier,   // remaining spend/points, or null if no nextTier
//   badges: [{ id, name, description, iconUrl, awardedAt }], // earned milestone badges
//   paidMembership: { status, cancelAtPeriodEnd, nextBillingAt, plan } | null, // see the Paid membership task
// }

if (status.program && status.enrolled) {
  render(`${status.pointsBalance} ${status.program.pointsName}`);
  // Points from an order the customer just placed are here, not in the balance.
  if (status.pendingPoints > 0) {
    render(`${status.pendingPoints} more arrive on ${status.pendingPointsConfirmAt}`);
  }
  if (status.nextTier) {
    render(`${status.pointsToNextTier} to reach ${status.nextTier.name}`);
  }
}

pointsBalance excludes points that are still pending, so a shopper who just ordered sees a balance with nothing in it. Render pendingPoints whenever it is above zero — it is the difference between "your points arrive on the 21st" and a bare 0 that reads as a broken programme. Points earned on an order stay pending for program.pendingDays (the merchant's return window, 14 by default) and are cancelled outright if the order is cancelled or fully refunded; pendingPointsConfirmAt is the night the oldest batch actually confirms, so it is safe to show as a date. A partial refund keeps every point the order earned — pending and confirmed alike — matching what a partial refund already did to the order's coupon redemption and the customer's lifetime spend. There is no proration, so do not tell the shopper their points will shrink by the refunded fraction; they will not.

program is null when the store has no loyalty program. tier/nextTier are null when the store hasn't configured tiers, or the customer hasn't qualified for one yet. When program.status !== 'ACTIVE', hide the loyalty UI.

Step 15.2: Enroll (only if not auto-enrolled)

If the merchant turned auto-enroll off, let the customer opt in:

await client.enrollInLoyalty(); // returns the same status shape; no-op if already enrolled

This may also grant a one-time "joined the program" bonus if the merchant has one configured. That is distinct from any account-signup bonus, which is granted automatically on registration, not by this call.

Step 15.3: List rewards and redeem

const rewards = await client.getAvailableRewards();
// [{ id, name, description, pointsCost, type, discountValue, minOrderAmount, minTierLevel, ... }]
// Already filtered to rewards the customer's current tier qualifies for.

// Redeeming spends points and mints a one-time coupon:
const { couponCode, discountType, discountValue, pointsBalance } =
  await client.redeemLoyaltyReward(rewardId);
await client.applyCoupon(cartId, couponCode); // applies at checkout via the normal coupon engine

reward.type is 'FIXED_DISCOUNT' (discountValue = currency amount off) or 'PERCENT_DISCOUNT' (discountValue = 0-100 percent off), and the minted coupon's discountType in the redeem result matches. The coupon applies through the same applyCoupon flow as any other coupon (usageLimit: 1). If the customer lacks enough points, or their tier is below reward.minTierLevel, redeemLoyaltyReward throws; if the coupon can't be minted, points are refunded automatically.

Step 15.4: Report a social share (optional)

If the merchant has a SOCIAL_SHARE earning rule configured, self-report a share to grant its bonus (once per customer):

await client.reportSocialShare('instagram'); // platform is optional, logging-only

Put the most relevant reward at the top of the list. The recommendation is ALWAYS one of the store's real rewards: the AI only ranks the catalog, and can never invent a discount. Rate-limited to 5/min per customer (it spends the merchant's AI credits):

const { reward, reason } = await client.getRecommendedReward();
// { reward: LoyaltyReward | null, reason: string | null, source: 'ai' | 'fallback' }
if (reward) renderHighlight(reward, reason); // reason is short, customer-facing copy

reward is null when the catalog is empty. Actual redemption still goes through redeemLoyaltyReward().

Step 15.6: Show earned badges (optional)

status.badges lists the milestone badges this customer earned (e.g. "10 orders"). These are display-only, with no discount attached; render iconUrl when present, else your own medal icon.

SituationWhat to do
program is nullStore has no loyalty program, so hide loyalty UI
program.status !== 'ACTIVE'Program paused or draft, so hide loyalty UI
enrolled === falseShow an "Join rewards" button → enrollInLoyalty()
pointsBalance < reward.pointsCostDisable that reward's redeem button
(tier?.level ?? -1) < reward.minTierLevelReward isn't redeemable yet; show what tier unlocks it
badges is emptyHide the badges shelf

Task 16: Referrals & birthday gifts

Grow the store with member share links (referrer earns points, referee gets a welcome coupon) and automated birthday gift emails. Both are merchant-enabled extensions of the loyalty program from Task 15.

Storefront + vibe-coded modes, same as Task 15. getReferralInfo() is the one loyalty call that needs no customerToken (it's meant for a public landing page).

When the merchant enables referrals, getLoyaltyStatus() returns a referralCode for enrolled members (lazy-created on first read; null when referrals are disabled or the customer isn't enrolled):

const status = await client.getLoyaltyStatus();
if (status.referralCode) {
  const shareUrl = `https://your-store.com/?ref=${status.referralCode}`;
  render(`Share your link and earn: ${shareUrl}`);
}

Step 16.2: Referral landing page (public, no login)

When a visitor arrives with ?ref=CODE, look it up before they sign up. The response never contains PII beyond the referrer's first name:

const info = await client.getReferralInfo(codeFromQuery);
// { valid, referrerFirstName, reward: { name, type, value, minOrderAmount } | null }

if (info.valid) {
  banner(`${info.referrerFirstName ?? 'A friend'} sent you a gift!`);
  // Persist the code (e.g. sessionStorage) until the visitor registers.
}

Step 16.3: Pass the code at registration

await client.registerCustomer({
  email,
  password,
  referralCode: codeFromQuery, // optional — invalid codes never fail registration
});

Validation (fraud checks included) happens asynchronously server-side. After login, the referee's welcome coupon, if the merchant configured one, appears on their loyalty status:

const { referralWelcomeCoupon } = await client.getLoyaltyStatus();
if (referralWelcomeCoupon) {
  // { code, type: 'PERCENTAGE' | 'FIXED_AMOUNT', value }
  await client.applyCoupon(cartId, referralWelcomeCoupon.code);
}

The referrer's points bonus is granted after the referee's first qualifying order (minimum order amount is merchant-configured) and held through the program's pending window like regular order points, so there is nothing for the storefront to do.

Step 16.4: Collect birthdays for the gift email

Birthday gifts need only profile data: month and day, no year (privacy), and both values must be sent together.

// Read it back first. getMyProfile() returns the saved birthday, so the form
// renders what the customer already gave you instead of asking again.
const profile = await client.getMyProfile();
// profile.birthMonth (1-12) / profile.birthDay (1-31), absent until set

await client.updateMyProfile({ birthMonth: 4, birthDay: 17 });

// Or collect it at signup, with no follow-up update call:
await client.registerCustomer({ email, password, birthMonth: 4, birthDay: 17 });

Both fields also come back on getCheckoutPrefillData().customer. February 29 is accepted and is celebrated on February 28 in non-leap years, so never block it in the picker. The day must exist in the month, so February 30 is rejected.

The field is optional unless the merchant turns on connection.requireBirthday (Task 1 capabilities, also on getStoreInfo().requireBirthday; default false, and an absent field means false), which makes both values mandatory at registration. It is enforced on vc_* sales-channel registration only. A storefront connected by plain storeId has no channel to read the flag from, so the API does not enforce it there, the same reach limitation requireEmailVerification already has. It never applies retroactively to customers who already registered.

It gates the password registration route only. OAuth sign-in creates the customer on a different path and so does guest checkout, and neither consults the flag. Turning it on therefore gives you a gated registerCustomer() form beside an ungated "Continue with Google" button on the same storefront, plus ungated guest checkout. Treat birthMonth/birthDay as optional everywhere except that one form, and never assume every customer row has them.

The platform emails a one-time gift coupon automatically ahead of the customer's birthday (merchant-enabled, merchant-picked reward), and only to customers with acceptsMarketing: true. No other storefront work.

SituationWhat to do
status.referralCode is nullReferrals disabled or customer not enrolled, so hide the share UI
getReferralInfovalid: falseUnknown or disabled code, so show normal signup without referral copy
reward is null on a valid codeReferrer still earns; there's just no referee welcome coupon
referralWelcomeCoupon is nullNone configured, or already used, so hide the coupon banner
Same visitor re-registers with a codeServer ignores it. One referral per customer, ever
profile.birthMonth is missingNo birthday on file yet; render the empty picker
connection.requireBirthday is trueRequire both fields on the vc_* password registration form (not enforced on storeId storefronts, OAuth sign-in or guest checkout)

Task 17: Paid loyalty membership

Sell a premium membership inside the loyalty program: a recurring charge (default every 30 days) that grants a points multiplier and merchant-described perks. The platform bills the customer's saved card automatically, with no Stripe UI on your side.

Storefront + vibe-coded modes, same as Task 15. All calls require a logged-in customerToken. Prerequisite: the customer must have a saved card, so vault one by passing saveCard: true at checkout (see the saved-cards section of the checkout docs).

Step 17.1: Show the plans

const plans = await client.getMembershipPlans();
// [{ id, name, perksDescription, priceAmount, billingIntervalDays, pointsMultiplier }]
// Empty when the store offers none or the program isn't active — hide the upsell.

Step 17.2: Pick a saved card

const methods = await client.getMySavedPaymentMethods();
// [{ id, paymentMethod, brand, last4, expMonth, expYear, isDefault }] — display fields only

If empty, prompt the customer to complete a purchase with "save my card" first, because there is no standalone card-entry flow here.

Step 17.3: Subscribe (charges immediately)

try {
  const membership = await client.subscribeToMembership({
    planId: plan.id,
    savedPaymentTokenId: method.id,
  });
  // membership.status === 'ACTIVE'; points now earn at plan.pointsMultiplier × tier multiplier
} catch (err) {
  // 409 with a code: 'card_declined' | 'requires_action' (bank demands 3D-Secure —
  // not supported off-session; ask for a different card) | 'provider_not_configured'
}

Step 17.4: Reflect and cancel

getLoyaltyStatus().paidMembership carries the live subscription state everywhere:

const { paidMembership } = await client.getLoyaltyStatus();
// { status: 'ACTIVE' | 'PAST_DUE' | 'CANCELLED', cancelAtPeriodEnd, nextBillingAt, plan } | null

const cancelled = await client.cancelMembership();
// End-of-period: perks continue until nextBillingAt, then no further charge.
// Re-subscribing to the same plan before period end un-cancels.
SituationWhat to do
getMembershipPlans() is emptyStore sells no memberships, so hide the upsell
No saved payment methodsPrompt a saveCard: true checkout before offering the subscribe CTA
Subscribe throws card_declinedLet the customer pick another saved card
status === 'PAST_DUE'Last renewal failed (retried daily; perks paused). Suggest re-subscribing with a working card
cancelAtPeriodEnd === trueShow "ends on {nextBillingAt}" + a resubscribe button

Task 18: Embeddable loyalty widget

A drop-in <iframe> version of the loyalty program for pages that aren't part of your SDK-connected storefront (a marketing microsite, a partner page, a legacy site the merchant hasn't migrated yet). The merchant must first turn this on and allowlist the embedding domain in the dashboard (Loyalty → Settings → Embeddable widget). The widget refuses to render on any domain not explicitly listed there.

Storefront + vibe-coded modes, requires customerToken. This is a separate opt-in from Tasks 15-17, so a store can have the regular loyalty SDK methods working while the widget stays off.

client.setCustomerToken(auth.token);

const { embedUrl } = await client.getLoyaltyWidgetSession();
<iframe src="{embedUrl}" width="360" height="420" style="border: 0" />

embedUrl is scoped to a ~15-minute session and is NOT the customer's real customerToken, so it's safe to pass into an iframe on a page you don't fully control. Re-mint it (e.g. on page load) rather than caching it long-term; a customer whose session has expired sees the widget refuse to render until the host page calls getLoyaltyWidgetSession() again.


Task 19: Gift cards

Let a shopper pay with a gift card at checkout.

All three modes (vc_* sales channel, storeId storefront, admin apiKey). In vc_* mode, build it when capabilities.features.hasGiftCards is true.

hasGiftCards is a per-store switch, not a count: a store that has issued no cards yet still reports true the moment the merchant turns the feature on. So build the field on the flag and it works the day the first card is issued.

In storeId mode there is no switch to read. The capabilities payload is sales-channel-only (getStoreCapabilities() throws a 400 in the other two modes), and unlike donations there is no gift-card flag on getStoreInfo(). So build the field unconditionally there. That is safe: unlike createDonation, applying a gift card is not gated on the merchant's switch — a code on a store that has issued none is simply refused like any other unusable code.

Admin mode does have a probe — getGiftCardLiability().enabled — but it gates issuing, not redemption, so it is not a reason to hide the redemption field either.

A gift card is a means of payment, not a discount. This is the one thing to get right, and it decides how the whole feature looks:

  • checkout.total does not change, and discountAmount does not move.
  • Tax is still calculated on the full order value.
  • What drops is checkout.providerAmountDue — what the payment provider will be charged.

Negative space, so you do not go looking: a shopper cannot buy a gift card on the storefront. There is no gift-card product type (products are SIMPLE, VARIABLE or KIT, and a KIT is a bundle of physical goods, not a gift card), and no storefront or sales-channel method issues one. Issuing is a scope-gated merchant operation, reachable from the dashboard and from an admin API key carrying the gift_cards:issue scope. The eight admin methods are documented in Gift cards (administration) in the SDK reference, and an API key must never be shipped to a storefront. The storefront's whole job is redemption. And once a card is issued there is no way to read its code back: only an HMAC of it and the last four characters are stored, so nothing — not the dashboard, not the API — can show a shopper their full code afterwards.

Step 19.1: Check a balance (optional)

const { balance, currency, usable } = await client.checkGiftCardBalance(code);
// { balance: '70.00', currency: 'ILS', usable: true }
POST /gift-cards/balance     { "code": "A1B2-C3D4-E5F6-G7H8-J9K0" }

usable: false with balance: "0.00" is the answer for a code that does not exist, one that is disabled and one that has expired — identical responses, returned after the same amount of time. Do not try to tell them apart; you cannot, by design (see Rules). Rate limited to 5 per minute, so make it an explicit "check balance" action rather than something you fire on every keystroke.

Step 19.2: Apply the card

const { tenderId, amountApplied, providerAmountDue } = await client.applyGiftCard(checkoutId, code);
// amountApplied:      '54.50'   — only what the order still owed
// providerAmountDue:  '150.50'  — what the card leaves for the payment provider
POST /checkout/{checkoutId}/gift-card     { "code": "A1B2-C3D4-E5F6-G7H8-J9K0" }

Only as much of the card as the order owes is taken, so a card larger than the basket leaves the rest on it for next time. Show the amount applied, never the card's balance: "Gift card −₪54.50" is true, "₪200 gift card applied" is not.

Every refusal is one HTTP 400 with one sentence — That gift card code cannot be used on this order. — for an unknown code, an expired one, a spent one, a disabled one and one in the wrong currency alike. Show a single "we can't use this code" and let the shopper re-enter it. (A card pays only in its own currency; there is no conversion.)

Two other 400s on this route are ordinary and can be explained: This order is already fully covered. (nothing left to pay) and CHECKOUT_LOCKED (payment has already started).

Step 19.3: Render from the checkout, not from your state

const checkout = await client.getCheckout(checkoutId);

checkout.total; // unchanged by any card
checkout.tenders; // [{ tenderId: 'tnd_1', amountApplied: '54.50' }], oldest first
checkout.providerAmountDue; // '150.50'

The hold lives on the server. A storefront that renders the response it kept from applyGiftCard shows an empty summary after a page refresh while the card is still applied — so the shopper applies a second one, or pays an amount that does not match what the provider will charge. Re-read the checkout and render checkout.tenders.

The summary gains two lines under the total, and changes nothing above it:

Subtotal      ₪160.00
Discount      −₪0.00
Shipping      ₪20.00
Tax           ₪25.00
Total         ₪205.00      ← unchanged; the amount tax was calculated on
Gift card     −₪54.50      ← one line per checkout.tenders entry
Amount due    ₪150.50      ← checkout.providerAmountDue

⛔ Never fold the card into the discount block and never subtract it from total. That shows the shopper, and prints on their receipt, a taxable base smaller than the one they were charged tax on.

If the shopper edits the basket after applying a card, the hold is clamped down to whatever the order is now worth. That is another reason to re-read the checkout rather than cache amountApplied.

Step 19.4: Remove a card

const { providerAmountDue } = await client.removeGiftCard(checkoutId, tenderId);
DELETE /checkout/{checkoutId}/gift-card/{tenderId}

By tenderId, never by code. A checkout can carry several cards, and the code is never echoed back to you. A stale id answers 404 No such tender on this checkout; re-read the checkout and render tenders again.

Nothing was ever debited while the card was applied — it was a hold — so removing it costs the shopper nothing and the value goes straight back.

Step 19.5: Pay whatever is left

Apply and remove cards before you create the payment intent. Once the checkout is PAYMENT_PENDING / PAYMENT_PROCESSING both calls fail with CHECKOUT_LOCKED, which is exactly what stops a card being applied behind a charge that was already quoted. Hide the field at that point.

Then branch on what is owed:

  • providerAmountDue > "0.00" → the normal payment step (Core, Task 5). The intent's amount already has the cards netted off server-side, so charge it as given and never subtract amountApplied yourself.
  • providerAmountDue === "0.00" → the cards cover the order. There is no provider charge to make: skip the payment step entirely and call completeCheckout(checkoutId). Completion is allowed without a captured payment in exactly this case, and the order it creates is a real paid order. Finish the same way you would after a payment: completeCheckout returns { orderId } so there is no waitForOrder poll, but you still owe the cart clear — client.handlePaymentSuccess(checkoutId) — or the shopper lands on the confirmation page with the items they just bought still in their cart.

Restaurant / build-your-own products

The basic modifier-group flow lives in Core Integration "Step 2.9". This section covers the three advanced restaurant features that ship on the same data model: scheduled availability, nested combos, and downsell modifiers.

Scheduled availability (breakfast-only menus)

Products can be flagged with one or more ProductAvailability rows: a list of weekdays + a daily start/end window in the merchant's timezone. The server applies the schedule when responding to GET /products/:id, and products outside their window return as unavailable for purchase, so the storefront can either hide them or render a "back at 07:00 tomorrow" notice.

You don't have to implement schedule resolution client-side; the server does it. If availabilityWindows is non-empty on the product response, you can show "Available 07:00 to 11:00, weekdays" as informational text. The server also blocks add-to-cart attempts outside the window.

Cross-day windows (e.g., late-night Fri → Sat 02:00) are split into two rows automatically by the server; the storefront just sees both windows in the response.

Nested combos (depth ≤ 3)

A modifier with referencedProductId opens a sub-flow on selection: the customer picks a "Burger" main from a "Combo" parent and then fills the burger's own modifier groups (doneness, cheese type, …). The server enforces a depth limit of 3 levels.

Modifier shape (parent):

{
  "id": "m_burger",
  "name": "Burger combo",
  "priceDelta": "0.00",
  "referencedProductId": "prod_burger"
}

When that modifier is selected, fetch /products/prod_burger, render its own modifierGroups, collect the customer's nested selections, and pass them to add-to-cart via nestedByModifierId keyed by the parent modifier id:

await client.addToCart(cartId, {
  productId: 'prod_combo_meal',
  quantity: 1,
  selections: [
    { modifierGroupId: 'mg_main', modifierIds: ['m_burger'] },
    { modifierGroupId: 'mg_drink', modifierIds: ['m_cola'] },
  ],
  nestedByModifierId: {
    m_burger: [
      { modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
      { modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
    ],
  },
});

The server validates each level independently and rejects the request with NESTED_DEPTH_EXCEEDED if you exceed 3 levels.

Cart persistence note (current limitation). The brain validates nested combos to depth 3, but the cart stores only the parent line + its direct modifiers. Nested combo prices do not yet bubble up to lineTotal; the parent modifier's own priceDelta is what counts. Persisting the full tree is a future schema change (PRD §15 Q5).

Downsell modifiers ("no bread −2₪")

Modifiers can carry a negative priceDelta, which is useful for "remove X for a discount" UX without the merchant having to maintain two SKUs.

{ "id": "m_no_bread", "name": "No bread (−2₪)", "priceDelta": "-2.00" }

Three rules the server enforces (you don't need to replicate client-side, but understanding helps):

  1. Negatives never consume a free slot. They apply at full negative value regardless of freeQuantity / freeAllocationPolicy.
  2. unitPrice cannot go below zero. If a downsell stack would push the unit below 0, the server rejects with MODIFIER_PRICE_FLOOR_VIOLATED (a generic 400 that leaks no internals). Fail gracefully and ask the customer to remove a downsell.
  3. No nested combos on downsells. A modifier cannot have both a negative priceDelta and a referencedProductId; the server rejects that on save.

Render downsells the same as positive modifiers; the only UX difference is the price label ("-2.00" instead of "+5.00").

Disable a group for a single variant

The merchant can set maxOverride: 0 on a per-variant attachment to hide the group entirely for that variant. For example, "Toppings" makes sense on Large pizza but not on the kids' Small. The product response uses max: 0 for that variant's effective group:

{ "id": "mg_toppings", "max": 0, "modifiers": [...] }

Treat max === 0 as "skip this group entirely": do not render it, and do not include it in selections. The validator silently skips it on the cart side.

Per-variant default selections / overrides

A merchant can override min / max / freeQuantity / required / defaultModifierIds for specific variants without redefining the group. The product response already returns the effective values for the active variant, so the storefront does not have to merge group defaults with variant overrides. Just render whatever is in product.modifierGroups[i] for the variant the customer is viewing.

If you switch the variant on the PDP, refetch the product (or use the variantId on the include) and re-render the modifiers, because different variants can have different effective groups.

Bulk catalog import (admin)

For seeding a store from a supplier feed, a CSV/Excel export, or a migration off another platform, bulkCreateProducts takes an array instead of one product per call. It is queued: the call returns a job id, and the products appear over the following seconds or minutes.

Requires an Admin-mode API key with the products:write scope.

const job = await admin.bulkCreateProducts({
  products: rows.map((r) => ({
    name: r.title,
    sku: r.sku,
    externalId: r.supplier_id, // your source-system id
    basePrice: Number(r.price),
    type: 'SIMPLE',
    categoryNames: [r.category], // auto-created if missing
    inventory: { total: Number(r.stock) },
  })),
  importId: 'supplier-feed-2026-08-21',
});

// The import has STARTED. It has not finished.
console.log(job.jobId, job.total);

Every field createProduct accepts is accepted per row: SIMPLE and VARIABLE, SKU/GTIN/MPN, sale and cost price, inventory, variants, categories, brands, tags, images, description and meta description, status, sales channels, tax behaviour, and translations.

Reading the result

const status = await admin.getBulkCreateProductsStatus(job.jobId);
// { total, processed, succeeded, failed, skipped, pending, status }

succeeded is what was created. skipped counts rows that already existed and were not created, so succeeded + skipped is not the number of products you imported.

COMPLETED_WITH_ERRORS means the import finished with some rows failing. That is not something to re-run; read the failures instead. Each carries the 1-indexed row from the array you submitted, so it maps back to the line of the source spreadsheet:

const { data, meta } = await admin.getBulkCreateProductsErrors(job.jobId, { limit: 100 });
// [{ row: 47, sku: 'TSH-001', productName: 'Tee', code: 'VALIDATION', message: '...' }]

Every failure is stored and nothing is truncated, so walk the pages when meta.totalPages > 1.

Catalogs of thousands

One request is capped at 1000 rows (500 is the comfortable size), because validating a large nested array costs real CPU in the request handler before any work is queued. Chunk the catalog, pass the same importId on every call, and poll once for the whole import:

const importId = `migration-${Date.now()}`;
for (const [i, chunk] of chunks(allProducts, 500).entries()) {
  await admin.bulkCreateProducts({
    products: chunk,
    importId,
    idempotencyKey: `${importId}-${i}`,
  });
}

const overall = await admin.getBulkCreateProductsImportStatus(importId);

The aggregate reports the least-complete state across the chunks and leaves finishedAt null until every one has finished, so a partial result can never read as a finished import.

Retries do not duplicate the catalog

A row whose sku or externalId already exists in the store is skipped rather than created again. That check is at the database level, so it holds regardless of how much time has passed or how the batch was chunked. Sending the same idempotencyKey returns the original job rather than starting a second import.

The gap to know about: a row carrying neither a sku nor an externalId has nothing to match on and will be created again by a re-send. Set externalId on rows without SKUs.

Pass conflictStrategy: 'error' when an existing SKU means the source file is wrong and you want it reported as a failure rather than quietly skipped.

Slugs are suffixed, not rejected

If a row's explicit slug is already in use, or two rows in the same file resolve to the same slug, the importer appends -1, -2 and imports the row. createProduct returns a 400 in the same situation. The difference is deliberate: a spreadsheet containing two "T-Shirt" rows should import rather than fail. It does mean the slug you sent is not always the slug you get, so read it back if you depend on it.

Sales channels

By default the per-product push to connected channels is suppressed during the import and one sync per affected channel is filed at the end, because connectors are rate-limited per catalog, and a per-product fan-out during a large import would exhaust those limits. Pass syncMode: 'none' to write to Brainerce only.

Product Review moderation (admin)

Reviews publish immediately on submit. Merchants hide individual reviews retroactively from the dashboard (or via API key with reviews:write scope).

List all reviews for a product (incl. hidden)

const { data, meta } = await client.adminListProductReviews('prod_123', {
  storeId: 'store_xyz',
  visibility: 'all', // 'visible' | 'hidden' | 'all'
  page: 1,
  limit: 50,
});

Admin responses include PII (customerId, authorEmail, orderId) and audit fields (hiddenAt, updatedAt) that storefront responses omit.

Hide / show a review

await client.hideProductReview('review_123', 'store_xyz');
await client.showProductReview('review_123', 'store_xyz');

Toggling hiddenAt automatically recomputes Product.avgRating + Product.reviewCount inside the same transaction.

Moderate a single photo

Photos are moderated per photo, not per review. A useful five-star review with one bad picture should lose the picture, not the review. The admin review list returns every photo, including ones hidden or still awaiting approval, in review.images.

await client.hideProductReviewImage('reviewimg_123', 'store_xyz');
await client.showProductReviewImage('reviewimg_123', 'store_xyz');

A photo is visible to shoppers only when approvedAt !== null && hiddenAt === null. Showing a photo that was never approved is what approves it, so stores using approval need no separate verb. Merchants do this from the product's reviews list in the dashboard; there is no separate moderation queue.

Disable reviews or review photos for a store

Three switches, all on Store and all editable at Settings → Reviews in the dashboard:

SettingDefaultEffect
reviewsEnabledtruefalse makes storefront review endpoints return 403 Forbidden. Admin endpoints still work, so existing reviews stay manageable and nothing is lost.
reviewPhotosEnabledtruefalse refuses photo uploads and imageKeys, leaving text reviews working.
reviewPhotosRequireApprovalfalsetrue holds each newly uploaded photo until a merchant shows it.

reviewPhotosRequireApproval is evaluated when a photo is written, not when it is read, so turning it on does not retroactively un-publish photos shoppers have already seen, and turning it off does not auto-approve the backlog.

Order custom fields (admin)

Merchant-defined fields that hold a value on an order. Distinct from checkout custom fields, which the shopper fills in: these belong to the store, and they are how you attach something that only exists after the purchase — a licence key, a booking reference, a warranty number issued by a third party.

What you write travels to the merchant's own order email templates as orderCustomFields, and shows on the customer's own order page when the field is marked public. So the customer can be told in the store's branding and language without you building an email.

No default template prints it. The variable reaches every order email, but until the merchant adds the block to their template once, a value you write is invisible to the customer. Writing the field is not the same as the customer being told — say so when you hand the integration over.

The merchant creates the definitions in the dashboard under Orders → settings. Requires an API key with orders:read / orders:write.

Discover the fields before writing

const definitions = await client.getOrderCustomFieldDefinitions();
// [{ key: 'licence_key', name: 'Licence key', type: 'TEXT', isPublic: true, isActive: true, ... }]

key is what you write. type is what a value has to fit. Inactive definitions are returned too, so you can tell "the merchant turned this off" apart from "the merchant never created it".

Write values

const stored = await client.setOrderCustomFieldValues(
  'order_123',
  { licence_key: 'ABCD-EFGH-IJKL' },
  { idempotencyKey: 'licence-order_123' }
);
console.log(stored.fields); // what was ACTUALLY persisted

The write is a merge: keys you omit keep their current value, and null clears a field that is not required.

Three behaviours to know before you rely on them:

  • An unknown key is ignored, not rejected. The call still returns 200. Read the returned fields to confirm your value landed rather than assuming success means it did.
  • Values are coerced to the field's type. A NUMBER field written as '5' reads back as 5. A value that cannot be coerced is a 400.
  • A required field cannot be cleared. Sending null for one is a 400.

Reading back

const { fields } = await client.getOrderCustomFieldValues('order_123');

The usual flow

This exists mainly to finish a purchase that depends on an external system: subscribe to order.paid, call whoever issues the thing, write the answer here, then move the order to COMPLETED so the order-completed email carries it. The full pattern, including what happens when the provider fails, is in Webhooks → Merchant Integration.

Content: advanced patterns

The basic Content flow (read chrome, list FAQ, fetch page by slug) lives in Core Integration → Content. The patterns below are advanced and merchant-driven, so implement them when the merchant asks.

Reserved key convention

Every Content type has 'main' as its universal default key. client.content.faq.get() (no args) resolves to key='main'. Topical keys use kebab-case: 'shipping', 'returns', 'holiday-2026', 'about', 'terms'. The regex enforced server-side is ^[a-z][a-z0-9-]*$, max 64 chars.

When designing your storefront, decide once whether each "section" is a single main row (one FAQ) or a set of topical rows (shipping + returns + ...). Don't mix the two; pick a convention and stick with it.

Channel scoping

Each Content row has salesChannelIds: string[]. Empty array = visible on every sales channel; non-empty = restricted to the listed channels.

In storefront mode (new BrainerceClient({ storeId })), channel scoping is bypassed, and all PUBLISHED rows are returned regardless of salesChannelIds. The storefront is responsible for not exposing channel-restricted content.

In vibe-coded mode (new BrainerceClient({ salesChannelId: 'vc_*' })), channel scoping is enforced by the server: a row with non-empty salesChannelIds is only returned when the active connectionId appears in that list.

Custom fields, the merchant-defined extras

Every Content row carries a free-form customFields: Record<string, string>. Merchants add arbitrary key-value pairs that you can opt into reading:

const faq = await brainerce.content.faq.get('shipping');
// faq.customFields might contain { helpEmail: '[email protected]', phone: '+1-555-0100' }
if (faq.customFields.helpEmail) {
  // render a contact link using faq.customFields.helpEmail
}

Keys must match ^[a-z][a-zA-Z0-9_]*$ (JS-identifier-safe). Values are always strings. Don't assume any specific keys exist; read defensively.

Translations, the per-locale overlay

Each Content row may carry translations: Record<string, Partial<data>> where keys are locale codes. The server resolves the overlay deep-merge-style with empty-string fallthrough:

  • Locale missing / null / === default → return data unchanged.
  • translations[locale] missing → return data unchanged.
  • Otherwise deep-merge: objects merge recursively, arrays merge by index, empty / missing overlay values fall through to base.

Storefronts do NOT need to do per-field overlay. Call client.content.faq.get('main', 'he') and render what comes back.

Caveat: arrays merge by index, not by stable id. If the merchant reorders FAQ items in the default locale, the locale overlay still references the old index. This is documented in the dashboard; in practice merchants rarely reorder.

Cache strategy

Public reads (/api/stores/:storeId/content/... and /api/vc/:connectionId/content/...) respond with:

Cache-Control: public, max-age=300, stale-while-revalidate=60

A merchant edit propagates to storefronts within ~5 minutes. Don't add extra client-side caching beyond Next.js's default fetch cache, because merchants expect their changes to surface within that window.

Admin reads (/api/v1/content/...) carry Cache-Control: no-store so the dashboard always sees the latest state.

Static page catch-all route

The recommended Next.js pattern for static pages is a single catch-all route under /pages/[slug]:

// app/pages/[slug]/page.tsx (Server Component)
import { notFound } from 'next/navigation';
import { getServerClient } from '@/lib/brainerce';
import { sanitizeHtml } from '@/lib/sanitize';

export async function generateMetadata({ params }) {
  const { slug, locale } = await params;
  const page = await getServerClient(locale).content.page.getBySlug(slug, locale);
  if (!page) return { title: slug };
  return {
    title: page.data.seo?.title ?? page.data.title,
    description: page.data.seo?.description,
    openGraph: page.data.seo?.ogImage ? { images: [{ url: page.data.seo.ogImage }] } : undefined,
  };
}

export default async function StaticPage({ params }) {
  const { slug, locale } = await params;
  const page = await getServerClient(locale).content.page.getBySlug(slug, locale);
  if (!page) notFound();
  return (
    <article>
      <h1>{page.data.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(page.data.html) }} />
    </article>
  );
}

Mount under /pages/[slug] (not the root) to avoid clashing with /cart, /login, etc.

Topical FAQs (multiple keys)

The simplest FAQ uses key 'main' and renders at /faq. To support topical sub-FAQs (shipping, returns, payment, ...), create one row per topic:

// Dashboard: create FAQ rows with keys 'shipping', 'returns', 'payment'
// Storefront: render the right one based on URL
const faq = await brainerce.content.faq.get(params.topic, locale);

Or expose a directory: client.content.faq.list(locale) returns all FAQs; render an index page that links to each by key.

Admin scripts (Node, server-side)

To seed content programmatically (CLI scripts, CI tasks, migrations), instantiate an admin client with apiKey:

import { BrainerceClient } from 'brainerce';

const admin = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });
const storeId = process.env.BRAINERCE_STORE_ID!;

await admin.content.faq.create(
  {
    key: 'shipping',
    name: 'Shipping FAQ',
    data: { items: [{ question: '', answer: '' }] },
  },
  storeId
);

// Read back, drafts included. The public get / list / getBySlug reads throw here.
const rows = await admin.content.listAdmin({ storeId, type: 'FAQ' });

storeId is a required trailing argument on every admin content and blog call, and it is not the constructor's storeId: that one only takes effect in storefront mode, so an admin client has no ambient store to fall back on. The SDK sends it as a query param, and the store scope guard rejects a call that omits it, or that names a store your key is not bound to, with 403 STORE_SCOPE_REQUIRED before the handler runs. The key needs content:read / content:write (blog:read / blog:write for blog posts).

API keys are server-only secrets. Never expose them in client code or commit them to git.

Storefront Bot (AI chat widget)

Add the store's AI shopping assistant to any page with one line, with no props and no config. Name, avatar, colors, greeting, starter questions, capabilities, and guardrails are normally configured by the merchant in the dashboard under Customers → Storefront Bot, and the widget renders nothing until the bot is switched Live there. These same settings are also readable/writable programmatically via the Admin SDK and MCP tools (see Programmatic configuration below), for teams that manage their store as code.

Zero-code embed (any site)

<script src="https://cdn.brainerce.com/bot.js" data-connection-id="vc_abc123" defer></script>

Keep the tag exactly this bare, and do not add integrity or crossorigin (the bootstrap is intentionally mutable so merchants never re-paste the tag on releases; it is origin-pinned by your CSP script-src instead).

SDK mount (React / Next.js / any bundler)

import { BrainerceBot } from 'brainerce/bot';

// e.g. in a useEffect or client entry — mounts a floating chat bubble
const bot = await BrainerceBot.mount({ connectionId: 'vc_abc123' });

// later, if needed:
bot?.destroy();

mount resolves to null when the bot is switched off or unconfigured for the connection, so it is safe to call unconditionally. If the store's AI credits run out, the widget still mounts but replies with a short "taking a break" message until credits reset.

OptionRequiredDescription
connectionIdyesYour vc_* connection id
baseUrlnoAPI origin override (self-hosted / staging)
targetnoMount element (defaults to document.body)
onAddToCartno({ productId, variantId, quantity }) => boolean | Promise<boolean>. Route the widget's cart adds through your own cart so your header count stays in sync. variantId is null for simple products. Return false to make the widget fall back to the product page.

The widget persists an anonymous chat session in localStorage, restores the conversation on revisit, streams answers token-by-token, and shows product recommendation cards (image, price, add-to-cart / view). Multi-variant products get an in-card variant picker (attribute chips with live variant price and image), and shoppers can simply ask the assistant to add an item to the cart. A "leave a message" form lands in the merchant's Inquiries inbox, and searches that match nothing feed the merchant's "unmet demand" analytics. Aside from cart adds the shopper initiates, the bot is read-only by design.

Where the bot is allowed to load. Every widget call (bootstrap, chat, escalation) is checked against the page's Origin and the domain configured on the connection, the same rule the rest of the storefront API uses. A Live connection accepts only its configured domain (exact host or a subdomain) plus any additional allowed origins it lists; a Test connection with no domain accepts any origin, which is what makes localhost and preview URLs work; a Test connection with a domain behaves like Live. A blocked origin is not an error. The bot simply does not render, indistinguishable from "switched off", so nobody can probe which connection ids exist. When a working storefront stops showing the bot on a new host, the connection's domain is the first thing to check. Server-rendered calls send no Origin: a Test connection without a domain accepts them and a Live connection refuses them, so mount client-side.

Add-to-cart resolution chain: your onAddToCart option → a cancelable brainerce:bot:add-to-cart CustomEvent on window (detail: { productId, variantId, quantity, connectionId }; call preventDefault() after handling) → navigation to the product page. Scaffolds from create-brainerce-store ≥ 1.45 wire onAddToCart for you.

Display behavior (compact / full-screen / shopper-resizable, position, auto-open) is controlled by the merchant in the dashboard, and the embed never decides it.

Programmatic configuration (Admin mode)

Read or write bot settings, covering name, avatar, persona (greeting, tone, starter questions, capabilities), and guardrails (avoidTopics, customInstructions), without touching the dashboard. Requires an Admin-mode API key with the bot-settings:read / bot-settings:write scopes (create one under Settings → API Keys).

import { BrainerceClient } from 'brainerce';

const admin = new BrainerceClient({
  apiKey: process.env.BRAINERCE_API_KEY,
  storeId: process.env.BRAINERCE_STORE_ID,
});

// Every connection on the store + its current (or default) settings
const { connections, settings } = await admin.getBotSettings();

// PATCH semantics — only the fields you pass are changed
await admin.updateBotSettings({
  salesChannelId: connections[0].salesChannelId,
  enabled: true,
  displayName: 'Maya',
  personaJson: {
    greeting: 'Hi! Looking for something specific?',
    tone: 'friendly',
    starterQuestions: ['What sizes do you carry?', "What's your return policy?"],
    avoidTopics: 'Do not discuss competitor pricing or make medical claims.',
    customInstructions: 'Always mention free shipping over $75.',
  },
});

MCP tools: get_bot_settings / update_bot_settings, guarded by the same bot-settings:read / bot-settings:write scopes.

Conversation transcripts (Studio inbox) and on-demand summarization are available the same way, gated by bot-conversations:read / bot-conversations:write:

const { data: conversations } = await admin.listBotConversations({ limit: 20 });
const transcript = await admin.getBotConversation(conversations[0].id);

// Persists a summary on the conversation; short threads (≤10 messages) are a
// no-op — summarized: false, no credits charged.
const { summarized, summary } = await admin.summarizeBotConversation(conversations[0].id);

MCP tools: list_bot_conversations / get_bot_conversation / summarize_bot_conversation.


Newsletter signup (marketing opt-in)

SDK >= 1.60. The email-capture popup, the footer subscribe bar, the exit-intent modal. You build the UI; Brainerce owns the contact, the consent record, and the confirmation email.

The one thing to get right

marketing.subscribe() does not subscribe anybody. It creates the contact and emails them a confirmation link, and the address stays unmailable until the recipient clicks it. A campaign can only reach people who clicked.

So on success, say "Check your email to confirm, including your spam folder", never "You're subscribed!". The second is untrue until a click lands in a mailbox you cannot see, and the spam-folder half is not padding: a confirmation that lands there is the single commonest reason a signup never becomes a subscriber, and the resend cooldown below means the visitor gets no second copy for 24 hours.

This is not a toggle. Single opt-in is not available, deliberately: without the click, anyone could subscribe anyone else's address, and for an Israeli sender under Amendment 40 the click is the evidence that the consent was the recipient's own.

SDK

await brainerce.marketing.subscribe({
  email: '[email protected]',
  locale: 'he', // language of the confirmation email
  source: 'popup', // free-form, for your own reporting
  honeypot: hiddenFieldValue, // must be empty
});

Optional: firstName, lastName, sourceMetadata (referrer, UTM params, the page the popup fired on).

Pass locale on a multi-language storefront. Omitted, the confirmation email falls back to the store's own language, so a Hebrew shopper on a bilingual site gets English. he and en are the two languages the confirmation is written in; anything else falls back to English.

REST

POST {baseUrl}/stores/{storeId}/marketing/subscribe
POST {baseUrl}/vc/{connectionId}/marketing/subscribe

{ "email": "[email protected]", "locale": "he", "source": "popup" }
→ 200 { "ok": true }

The response tells you nothing, on purpose

{ ok: true } comes back identically for a brand-new address, one that already confirmed months ago, one still inside its cooldown, and one suppressed after a hard bounce. A response that distinguished them would turn a public form into a way to test whether a given person shops at this store.

Render one message for every success. There is no branch to write.

Limits

WhatLimit
Requests per IP3 / minute
Confirmation emails per address, per store1 / 24 hours

The 24-hour cooldown is silent: a second submission inside it still returns { ok: true } and simply sends nothing. It exists because an unauthenticated endpoint whose job is to email a stranger is an email-bombing tool otherwise.

Render the honeypot field as a hidden input and pass whatever it contains. A non-empty value rejects the request; bots fill every text input, humans never see this one.

The "10% off your first order" part

SDK >= 2.7. The merchant configures a welcome offer in the dashboard, and the platform mints a personal, one-time coupon for anyone who confirms their signup. Your job is to advertise it, never to issue it.

const offer = await brainerce.marketing.getBenefit('he'); // locale for headline + terms
// → null when this store offers nothing: render the plain form and promise nothing
// → { discountType: 'PERCENTAGE', discountValue: 10, validityDays: 7,
//     minimumOrderAmount: 200, maximumDiscount: null, firstOrderOnly: true,
//     headline: '10% הנחה על ההזמנה הראשונה', terms: '…' }
GET {baseUrl}/stores/{storeId}/newsletter-benefit?locale=he
GET {baseUrl}/vc/{connectionId}/newsletter-benefit?locale=he
→ 200 null   (no offer)

⛔ 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 at that moment, which is also what stops a forwarded link handing the discount to someone who never asked for it. The confirmation page is served by the API and already shows the code, its expiry and its terms, so there is nothing to poll for and no "preparing your code" screen to build.

getBenefit() takes no email address and never will. The offer belongs to the store, not to the visitor. A response that varied per address would be an unauthenticated way to test who already subscribed or claimed, which is the same leak the uniform { ok: true } above exists to prevent. To discourage a repeat signup, say the offer is one per address; do not try to detect one.

One per address, per store, forever. A second signup, a re-subscribe after unsubscribing, and a double-clicked confirmation link all resolve to the benefit that already exists. Existing subscribers get nothing retroactively, and a contact imported from CSV is not eligible however they arrive at the confirmation link.

The older pattern still works and is a different thing: one coupon with the customer_first_order condition, shown as a fixed code after a successful call. It hands the same code to everyone who opens the popup, and anyone who saw it once can pass it on.

What lands where

The contact appears under Dashboard → Customers immediately, with Accepts marketing off. It flips on when they confirm. Campaign audiences filter on that flag, so an unconfirmed contact is visible to the merchant but unreachable by a send.

A newsletter contact is a normal guest customer record, with no password and no account. If that same person later registers or checks out, it is the same row.

This is not the contact-form path. createInquiry({ formKey: 'newsletter' }) files a message under Customers → Inquiries and never touches marketing consent. Use it for "get in touch", not for a mailing list.


Back-in-stock alerts

SDK >= 1.61. The "email me when this is back" button on a sold-out product. You build the button; Brainerce watches the stock and sends the message.

The one thing to get right

This is not a newsletter signup and must not be worded as one. Nothing is subscribed, no customer account is created, and the person receives exactly one email: the alert itself, about that item, with a link that stops it.

So label it "Email me when this is back", never "Subscribe". Someone who thinks they joined a mailing list and then gets nothing has been misled; someone who thinks they joined and never wanted to has a complaint.

The flip side matters as much: a shopper who unsubscribed from your marketing can still use this, so never hide the button from them or gate it on consent.

Show it only where it applies

The alert exists for one situation: the item is out of stock and cannot be backordered. Everything else is silently ignored server-side, which means a button rendered in the wrong place looks like it worked and does nothing.

Do not render it when:

  • the merchant switched the feature off, so read stockAlertsEnabled from getStoreInfo() at boot and hide the whole affordance when it is false;
  • the item is in stock;
  • the merchant allows backorders on it (the storefront can already sell it out of stock, so there is nothing to wait for);
  • inventory is not tracked for it, or tracking is UNLIMITED / DISABLED.
const store = await brainerce.getStoreInfo();
// `inv` is the SELECTED VARIANT's inventory when there is one, else the product's.
const inv = selectedVariant?.inventory ?? product.inventory;

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

backorderMode is on InventoryInfo from SDK 1.61. Older backends omit it, so treat undefined as 'NONE'.

The merchant controls the switch under Channel settings → Inventory, alongside the low-stock warning. The same screen sets how many waiting shoppers are emailed per unit that returns.

SDK

await brainerce.stockAlerts.subscribe({
  email: '[email protected]',
  productId: product.id,
  variantId: selectedVariant.id, // pass this on any product with variants
  locale: 'he', // language of the alert email
  honeypot: hiddenFieldValue, // must be empty
});

Pass variantId on every variable product. Without it the alert waits on the product as a whole, so a shopper who wanted the medium hears when the small comes back and arrives to find their size still gone.

Pass locale on a multi-language storefront. Omitted, the alert falls back to the store's own language. he and en are the two languages the alert is written in; anything else falls back to English.

REST

POST {baseUrl}/stores/{storeId}/stock-alerts
POST {baseUrl}/vc/{connectionId}/stock-alerts

{ "email": "[email protected]", "productId": "clx...", "variantId": "clx...", "locale": "he" }
→ 200 { "ok": true }

The response tells you nothing, on purpose

{ ok: true } comes back identically for a new request, a duplicate, a product id that does not exist, an item that is already in stock, and an address suppressed after a hard bounce. A response that distinguished them would let anyone read your stock levels, or test whether a given person shops here.

Render one message for every success, "We'll email you when it's back", and write no branch.

When the email actually goes out

Not the instant the number moves. Availability is total - reserved, so every abandoned cart briefly pushes a sold-out item back above zero; mailing on that would send people to a page that still says sold out. The alert fires only after stock has held for a few minutes, checked every two minutes.

Waves, not a stampede. If 500 people are waiting and 3 units arrive, roughly 9 alerts go out, oldest request first. The rest keep their place in the queue for the next restock. This is deliberate: mailing all 500 about 3 units manufactures 497 disappointed shoppers.

The ratio is the merchant's to set (Channel settings → Inventory, "People per unit", default 3). A merchant running a drop can set it to 0, which mails the whole waitlist the moment stock lands. Your UI copy should not assume either.

So a shopper may wait through a restock without hearing. That is working as intended, and it is worth saying so in your UI copy if you promise anything at all about timing.

Limits

WhatLimit
Requests per IP5 / minute
Open alerts per address, per store25
How long an unfired alert lives90 days
Alerts sent per requestexactly 1

A second request for the same item from the same address is a no-op, not a second alert. Once an alert has fired the person can ask again the next time that item sells out.

Render the honeypot field as a hidden input and pass whatever it contains. A non-empty value rejects the request; bots fill every text input, humans never see this one.

What the merchant sees

Dashboard → Inventory → Restock Waitlist lists the products people are waiting for, biggest waitlist first, with the addresses behind each number. It is a reorder signal, not a mailing list: there is no way to send those people anything else from there, by design.

What this does not do

  • No SMS or WhatsApp. Email only.
  • No price-drop alerts. Availability only.
  • No merchant-editable template. The alert body is fixed. That is deliberate: the moment it could carry a discount code it would become a marketing message needing an unsubscribe link and a postal address it does not have.
  • No "notify me" on backorderable items. See above.

Donations

SDK >= 2.1. A donation page, for a store that takes gifts as well as — or instead of — selling things.

A donation is not a checkout

It has no line item, no quantity, no shipping, no order, and it is reported separately from sales. It has its own pair of methods and shares nothing with the cart.

The tell that you have modelled it wrong is the amount. A cart cannot let a donor type one. If you are creating a "Donation $18" product, or six of them at different prices, stop: besides forcing the donor onto your ladder, it files every gift into the merchant's sales figures, where it does not belong.

The store has to have opened donations first

getStoreInfo() reports donationsEnabled. It is a switch the merchant flips, not something derived from data.

This surface does not auto-hide, and that makes it different from everything else in this guide. Elsewhere you build the component early and it renders nothing until the merchant configures the feature. Here, createDonation is rejected while donations are closed — so a page built early collects a donor's name, email and card details and then fails on submit. Gate the page.

const store = await brainerce.getStoreInfo();
if (!store.donationsEnabled) return null; // no donation page for this store

Use getStoreInfo(), not getStoreCapabilities(). The capabilities call carries the same fact as features.hasDonations, but it only works in vibe-coded mode — getStoreInfo() works in both. And treat an absent field as closed: an older backend simply omits it.

The form

One page, one form. Everything below is on it:

  • Preset amounts plus a free "other amount". The presets are the organisation's own ladder — 18 / 36 / 72 / 180 / 360 / 1000 is a common one. The free field is the point of the whole feature, so do not drop it.
  • Email, required. Name, optional.
  • Tribute, optional: IN_HONOR for someone living, IN_MEMORY for someone who has died, plus the name. The API rejects a tribute with nobody named, so make the name required the moment a tribute type is chosen.
  • Anonymous, optional. Say what it actually does on the form: it hides the donor's name on public surfaces, and the organisation still sees who gave. Donors read "anonymous" as "untraceable", and it is not.
  • Cover the processing fee, optional — and worth building, because between 55% and 60% of donors accept it when a form offers it. It is charged on top of the gift: amount stays what the donor gave, feeCoverAmount is extra, chargeAmount is what the card is charged. Never subtract it from the gift.
  • Message, optional, plain text. Never render it as HTML.

SDK

const donation = await brainerce.createDonation({
  amount: 180, // the gift
  feeCoverAmount: 6.3, // only when the donor ticked the box
  donorEmail: '[email protected]',
  donorName: 'Sarah Cohen',
  isAnonymous: false,
  tributeType: 'IN_MEMORY',
  tributeName: 'Avraham Cohen',
  message: 'From the whole family.',
  returnPath: '/thank-you',
});

returnPath is a path on your own storefront, not a URL. A full URL is rejected: the payment provider redirects a real browser to this value, so accepting one from the page would be an open redirect.

Then complete donation.payment with the provider. It is the same shape as a checkout intent — clientSecret for an embedded SDK, redirectUrl for a hosted page.

The mistake everybody makes

createDonation resolving is not a completed gift.

It comes back as status: 'PENDING' with a provider intent, and no money has moved. The gift becomes PAID only when the provider's webhook confirms it — exactly like an order, which is why you call waitForOrder there rather than trusting the payment callback.

So do not thank the donor, do not show the amount as given, and do not send anything that reads like a receipt until this says PAID:

const settled = await brainerce.getDonation(donation.donationId);
if (settled.status === 'PAID') {
  // donorName comes back null on an anonymous gift, so this is safe on a public page
  show(`Thank you, ${settled.donorName ?? 'friend'}`);
}

Both calls are rate limited to 5 requests per minute. Create is capped because an unauthenticated endpoint that mints payment intents is a card-testing instrument; read-back is capped because an id that either resolves or 404s can be enumerated. Poll a handful of times after the donor returns, not on a one-second interval.

What is not there

Say these out loud to the merchant rather than letting them assume:

  • No receipts. Brainerce records the donation. It does not issue a tax receipt and does not file anything with any tax authority — that stays with whatever the organisation already uses.
  • No recurring donations from the storefront. Standing orders exist, but a donor cannot start one themselves: the merchant arms it from the dashboard, for a customer who already saved a card at a previous checkout. Do not build a "monthly" toggle that calls createDonation — it would quietly produce a one-off gift.
  • No funds, campaigns or goal meters.
  • Admin API keys cannot start a donation. Both methods are vibe-coded (salesChannelId) or storefront (storeId) mode only.

Traffic analytics (built-in, no GA4 needed)

Brainerce has a native cookieless analytics pipeline covering visits, visitors, countries, sources, devices and the conversion funnel, visible under Dashboard → Traffic. You don't need GA4, Meta Pixel, or any third-party script.

Add one line to your root layout <head>:

<!-- Vibe-coded (salesChannelId) mode -->
<script defer src="https://api.brainerce.com/t.js" data-channel="vc_abc123"></script>

<!-- storeId mode -->
<script defer src="https://api.brainerce.com/t.js" data-store-id="your_store_id"></script>

That's it. The pixel auto-sends a pageview beacon on load and on every SPA route change (patches the History API), de-dupes consecutive identical paths, and measures active dwell time. Scaffolds from create-brainerce-store ≥ 1.50 include it automatically.

Option B: SDK method

If you prefer a JS import over a script tag, or need to fire custom events:

import { BrainerceClient } from 'brainerce';

const client = new BrainerceClient({ salesChannelId: 'vc_abc123' });

// Pageview — call on every route change
client.trackEvent({ eventType: 'pageview', path: window.location.pathname });

// With UTM attribution
const params = new URLSearchParams(window.location.search);
client.trackEvent({
  path: window.location.pathname,
  utmSource: params.get('utm_source') ?? undefined,
  utmMedium: params.get('utm_medium') ?? undefined,
  utmCampaign: params.get('utm_campaign') ?? undefined,
  screenWidth: window.innerWidth,
  lang: navigator.language,
});

// Engagement dwell time (send on route change or pagehide)
client.trackEvent({ eventType: 'engagement', path: '/products/shoes', engagedMs: 12000 });

trackEvent() is fire-and-forget, and a failed beacon never throws or breaks your storefront.

Don't use both. If you load t.js, skip trackEvent() for pageviews, because the pixel already handles them. Use the SDK method only when you're not loading the script tag.

Privacy & CSP

Privacy: cookieless, no PII stored. The visitor IP is resolved to a country server-side and then discarded. No cookie-consent banner required under GDPR/ePrivacy.

CSP: if your storefront sets a Content-Security-Policy, the script loads via its nonce and the beacon needs the API origin in connect-src:

script-src  ... 'nonce-<your-nonce>'
connect-src ... https://api.brainerce.com

The merchant can toggle tracking per sales channel in the dashboard.


Marketing tags (GA4, GTM, Meta pixel, TikTok pixel)

Separate from the built-in traffic analytics above: these are the merchant's own advertising tags, used to optimize ad campaigns and build remarketing audiences.

You never ask the merchant for a tag id. They arrive in getStoreInfo().tracking, resolved server-side from the marketplace apps the merchant already connected. Connecting the Google app runs GA4 discovery and the measurement id appears on its own; the Meta Commerce app does the same for the pixel. Nothing is typed and the storefront never redeploys; a newly connected app shows up within 5 minutes.

Boot the tags

const storeInfo = await client.getStoreInfo();

// Once, as early as possible (app entry / root layout).
// Boots whichever of GA4 / GTM / Meta / TikTok is configured; skips the rest.
client.initTracking(storeInfo.tracking);

storeInfo.tracking is { ga4MeasurementId?, gtmContainerId?, metaPixelId?, tiktokPixelId? }. An empty object is the normal state for a store with no tag app connected, not an error.

tiktokPixelId is populated when the merchant types a pixel ID into the TikTok app's storefront-pixel field in the dashboard (a hand-typed value, because there is no TikTok auto-discovery yet). When present, initTracking loads TikTok's pixel and trackMarketingEvent reports to it exactly as it does for Meta; when absent, it is skipped. Treat it like every other key in the shape: optional, and controlled entirely from the dashboard.

Report events

Describe what the shopper did once, in GA4's vocabulary. The SDK translates to each vendor (dataLayer push, fbq standard event, ttq event):

client.trackMarketingEvent('view_item', { currency, value: price, items: [item] });
client.trackMarketingEvent('add_to_cart', { currency, value, items });
client.trackMarketingEvent('begin_checkout', { currency, value, items });

// On the order-confirmation page
client.trackMarketingEvent('purchase', {
  transactionId: order.id,
  currency: order.currency,
  value: parseFloat(order.totalAmount),
  items: order.items.map((i) => ({
    itemId: i.sku,
    itemName: i.name,
    price: parseFloat(i.price),
    quantity: i.quantity,
  })),
});

Event names: view_item · view_item_list · add_to_cart · remove_from_cart · view_cart · begin_checkout · add_payment_info · purchase · search · sign_up.

Fire these even if you also load a GTM container. A container with no dataLayer events is an empty container, with nothing to trigger on.

Two rules that break things silently

itemId must be the SKU. That is the id Brainerce publishes to the Google Merchant Center and Meta catalog feeds, so it is the only id the ad platforms can match a pixel event against. Send a product or variant id and attribution plus dynamic remarketing stop working with no error anywhere: the events arrive, the platform reports an id its catalog has never seen, and the audience never builds.

Pass transactionId on purchase. It becomes GA4's transaction_id, Meta's eventID and TikTok's event_id, so a shopper refreshing the confirmation page de-duplicates on the vendor's side instead of double-counting revenue. Guard the call yourself too (a sessionStorage key per order id) so the send doesn't repeat at all.

CSP

If your storefront sets a Content-Security-Policy, the tags load and then every hit is blocked unless these hosts are in connect-src, a failure that looks exactly like "no sales" in ad reporting:

connect-src ... https://www.googletagmanager.com https://www.google-analytics.com
                https://*.google-analytics.com https://*.analytics.google.com
                https://stats.g.doubleclick.net https://www.google.com
                https://connect.facebook.net https://www.facebook.com
                https://analytics.tiktok.com

Under script-src 'strict-dynamic' you do not add these to script-src. Host allowlists are ignored there, and trust propagates from your nonce'd bundle to the tag scripts it injects.

Unlike Brainerce's cookieless traffic analytics, these tags set cookies and are subject to consent law. If the store sells to the EEA or UK, gate initTracking() behind a consent banner or implement Google Consent Mode v2. The SDK does not do this for you.

GA4 server-side conversions come free

Calling initTracking() with a GA4 id also switches on Brainerce's server-side purchase conversion: the SDK captures gtag's client_id/session_id and attaches them to cart and checkout calls, which is what lets the backend send a purchase event that lands in the right GA4 session instead of orphaning. That recovers the conversions client-side tags lose to ad-blockers and to shoppers who close the tab after paying.


App Store Shipping (live carrier rates)

Merchants can install a shipping carrier app from the Brainerce App Store (EasyPost, Shippo, or any future carrier) to get live rates at checkout and purchase labels from their own carrier account. Billing goes directly to the merchant's carrier account, and Brainerce is never a billing intermediary.

Your storefront code is carrier-agnostic: every carrier app speaks the same Brainerce shipping contract, so the shapes below never change when a merchant switches providers.

How rates appear at checkout

Once a merchant installs a shipping app and configures their ship-from address, Brainerce automatically merges live carrier rates into the GET /checkout/:id/shipping-rates response alongside any manually configured zone rates.

const rates = await client.getShippingRates(checkoutId, { country: 'US', ... });
// rates may include entries like:
// { id: 'carrier:rate_abc123', name: 'USPS Priority Mail', price: '8.50', currency: 'USD', source: 'carrier' }
// { id: 'manual_zone_1', name: 'Standard Shipping', price: '5.00', currency: 'USD', source: 'manual' }

Rates with source: 'carrier' are live quotes; their id is prefixed carrier:. Treat the id as opaque and pass it back whole as shippingRateId when completing checkout. Never parse or strip the prefix.

Live rates are quoted in the merchant's carrier-account currency, which may differ from the checkout currency. Brainerce does not convert.

Purchasing a label (admin / backend)

After the order is placed, a merchant can purchase a shipping label via the admin SDK. Rate ids from getOrderShippingRates() are passed through unmodified:

const label = await client.createShippingLabel(orderId, {
  rateId: 'rate_8f123456789abcdef', // opaque rate id from the shipping app
  labelFormat: 'PDF', // optional: 'PDF' | 'PNG' | 'ZPL' | 'EPL'
});

console.log(label.labelUrl); // Label file URL
console.log(label.trackingNumber); // e.g. '9400111899223397662488'
console.log(label.carrier); // e.g. 'USPS'
console.log(label.labelFormat); // what the carrier actually produced

labelFormat defaults to PDF. ZPL and EPL drive warehouse thermal printers directly. A carrier that cannot produce the requested format returns its closest match instead of failing the purchase. The postage is already paid at that point, so read labelFormat off the response rather than assuming.

The label purchase is charged directly to the merchant's carrier account. Brainerce stores the trackingNumber and labelUrl on the Order so they appear in the customer's order history automatically.

Buy the service the shopper paid for. order.shippingSelection records the live carrier service that was sold at checkout as { carrier, service, methodName, amount }, or null when the order sold a flat-rate or zone rate and there is nothing to match. Rate ids do not survive a re-quote, so re-find the service on carrier + service, trimmed and lower-cased:

const order = await client.getOrder(orderId);
const paidFor = order.shippingSelection;
const norm = (v?: string | null) => (v ?? '').trim().toLowerCase();

const preferred = paidFor
  ? rates.find(
      (r) => norm(r.carrier) === norm(paidFor.carrier) && norm(r.service) === norm(paidFor.service)
    )
  : undefined;

Buying a cheaper, slower service than the one the shopper was charged for is a silent downgrade of what they bought. When the paid-for service is not in the fresh quote, surface that and let a human choose. Never substitute one automatically.

shippingSelection is an admin field: getOrder() and getOrders() return it, the buyer-facing order endpoints do not.

Tracking updates

There is nothing to integrate, because tracking is automatic once a label exists.

The carrier sends status webhooks to the installed shipping app, which translates them into Brainerce's neutral event vocabulary. The platform then appends the tracking history, advances the shipment, and, on delivery, completes the order and sends the customer notification.

Shipment reachesWhat the platform does
In transit / out for deliveryAppends tracking history, advances shipment status
DeliveredCompletes the order, sets deliveredAt, sends the completion email
Returned / failed / cancelledRecords the exception on the shipment

Redelivered and out-of-order events are safe: duplicates are dropped, and a stale status can never move a shipment backwards or re-send a customer email.

Read the history with getOrderShipments(orderId). Each shipment carries its tracking events, newest first, with structured locations:

const shipments = await admin.getOrderShipments(orderId);

for (const s of shipments) {
  console.log(s.carrier, s.trackingNumber, s.status);
  // Capped at the 200 most recent events per shipment
  for (const e of s.events) {
    console.log(e.occurredAt, e.message, e.location?.city, e.location?.country);
  }
}

Cross-border shipments

Customs is handled for you. When the destination country differs from the merchant's ship-from country, the platform builds a declaration from the order's line items and attaches it, so you pass nothing extra. Use customsContentsType only when the parcel is a gift, sample, document or return rather than merchandise.

Two things are worth knowing:

Quote immediately before you buy. getOrderShippingRates() is what creates the shipment at the carrier, and carriers do not allow amending one afterwards. Everything the label needs, the declaration included, is fixed at that moment, so a rate held from an earlier call may no longer be purchasable.

US exports above $2,500 need an ITN. The ordinary EEI exemption (NOEEI 30.37(a)) asserts that no single commodity line exceeds $2,500, which is a statement to US Customs. Brainerce will not assert it when the declared values say otherwise, so the carrier refuses the label until the exporter files with AES and supplies a real citation. That refusal is correct, not a bug.

SDK mode required: createShippingLabel() requires an apiKey, because it is an admin operation and is not available in storefront (storeId) mode.

On this page

Task 8: Customer accountsStep 8.1: RegisterStep 8.2: LoginStep 8.3: Merge guest cart after loginStep 8.4: Email verification (if required)Step 8.5: Password resetStep 8.6: LogoutTask 9: Customer profile & order historyStep 9.1: Get profileStep 9.2: Update profileStep 9.3: Saved addressesStep 9.4: Order historyStep 9.5: Pre-fill checkoutStep 9.6: Display buyer customizations on past ordersStep 9.7: Show shipping address and tracking on past ordersStep 9.8: Show payment statusStep 9.9: Show the order status timelineTask 10: Social login (OAuth)Step 10.1: Show buttonsStep 10.2: Start OAuthStep 10.3: Handle callbackStep 10.4: Manage linked accountsTask 11: Discount banners & promotionsDiscount banners (site-wide)Product discount badgeCart nudgesTask 12: Upsells, bundles, bumps and upgradesConsolidated cart includes (recommended)Cart bundlesCart upgradesCheckout order bumpsTask 13: Inventory countdown & reservationCheck availabilityReservation countdownTask 14: Digital product downloadsGuest downloadsCustomer downloadsResponseTask 15: Loyalty & rewardsStep 15.1: Show the customer's statusStep 15.2: Enroll (only if not auto-enrolled)Step 15.3: List rewards and redeemStep 15.4: Report a social share (optional)Step 15.5: "Recommended for you" (optional, AI)Step 15.6: Show earned badges (optional)Task 16: Referrals & birthday giftsStep 16.1: Give members their share linkStep 16.2: Referral landing page (public, no login)Step 16.3: Pass the code at registrationStep 16.4: Collect birthdays for the gift emailTask 17: Paid loyalty membershipStep 17.1: Show the plansStep 17.2: Pick a saved cardStep 17.3: Subscribe (charges immediately)Step 17.4: Reflect and cancelTask 18: Embeddable loyalty widgetTask 19: Gift cardsStep 19.1: Check a balance (optional)Step 19.2: Apply the cardStep 19.3: Render from the checkout, not from your stateStep 19.4: Remove a cardStep 19.5: Pay whatever is leftRestaurant / build-your-own productsScheduled availability (breakfast-only menus)Nested combos (depth ≤ 3)Downsell modifiers ("no bread −2₪")Disable a group for a single variantPer-variant default selections / overridesBulk catalog import (admin)Reading the resultCatalogs of thousandsRetries do not duplicate the catalogSlugs are suffixed, not rejectedSales channelsProduct Review moderation (admin)List all reviews for a product (incl. hidden)Hide / show a reviewModerate a single photoDisable reviews or review photos for a storeOrder custom fields (admin)Discover the fields before writingWrite valuesReading backThe usual flowContent: advanced patternsReserved key conventionChannel scopingCustom fields, the merchant-defined extrasTranslations, the per-locale overlayCache strategyStatic page catch-all routeTopical FAQs (multiple keys)Admin scripts (Node, server-side)Storefront Bot (AI chat widget)Zero-code embed (any site)SDK mount (React / Next.js / any bundler)Programmatic configuration (Admin mode)Newsletter signup (marketing opt-in)The one thing to get rightSDKRESTThe response tells you nothing, on purposeLimitsThe "10% off your first order" partWhat lands whereBack-in-stock alertsThe one thing to get rightShow it only where it appliesSDKRESTThe response tells you nothing, on purposeWhen the email actually goes outLimitsWhat the merchant seesWhat this does not doDonationsA donation is not a checkoutThe store has to have opened donations firstThe formSDKThe mistake everybody makesWhat is not thereTraffic analytics (built-in, no GA4 needed)Option A: Script tag (recommended)Option B: SDK methodPrivacy & CSPMarketing tags (GA4, GTM, Meta pixel, TikTok pixel)Boot the tagsReport eventsTwo rules that break things silentlyCSPConsentGA4 server-side conversions come freeApp Store Shipping (live carrier rates)How rates appear at checkoutPurchasing a label (admin / backend)Tracking updatesCross-border shipments