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
storeConfigobject 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_orderdiscount 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 accountAgainst 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-verificationRequires 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
resetUrlfield in the body is rejected, not ignored. The API runsforbidNonWhitelisted, so the request fails with HTTP 400 andproperty 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/meThe 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
| Action | Method | Endpoint |
|---|---|---|
| List | GET | /customers/me/addresses |
| Add | POST | /customers/me/addresses |
| Update | PATCH | /customers/me/addresses/{addressId} |
| Delete | DELETE | /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=10Returns 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-prefillReturns 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 type | Value shape | Render as |
|---|---|---|
TEXT, TEXTAREA, URL | string | plain text |
NUMBER | stringified number | plain text |
BOOLEAN | "yes" / "no" | checkmark / ✗ |
SELECT | string | the chosen option |
MULTI_SELECT | string[] | comma-separated |
IMAGE | asset URL (string) | thumbnail linking to the full-size asset |
GALLERY | string[] of URLs | grid of thumbnails |
COLOR | hex string | colored swatch |
DATE, DATETIME | ISO-8601 string | localized date |
Notes:
- The
customizationsmap is a snapshot. Even if the merchant later renames or deletes the underlyingMetafieldDefinition, the order keeps the originallabelandvalueintact. - Fields flagged as
appliesToAllProducts: trueon theirMetafieldDefinitionstill appear in per-itemcustomizationson every order item that had values submitted for them, so no special handling is needed on display. - The
typevalue mirrors theMetafieldTypeenum (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:
| Field | Shape | Notes |
|---|---|---|
shippingAddress | { firstName, lastName, company?, line1, line2?, city, region, postalCode, country, phone? } | A snapshot at checkout; it won't change afterwards. |
trackingNumber | string | null | Carrier tracking number. |
trackingUrl | string | null | Direct tracking URL. Render as an <a> when present. |
carrier | string | null | Carrier name (e.g., "UPS", "DHL"). |
shippedAt | ISO-8601 string | null | When the order left the warehouse. |
deliveredAt | ISO-8601 string | null | When 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.
| Field | Values |
|---|---|
paymentMethod | e.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:
financialStatus | Badge color |
|---|---|
paid | green |
pending | amber |
refunded | neutral |
partially_refunded | amber |
voided | red |
{
(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/callbackProvider: google, facebook, or github (lowercase).
redirectUrlis not the provider callback URL. They are two different addresses.redirectUrlis 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 OAuthstate. The address registered with Google/Facebook/GitHub is Brainerce's own, fixed for every store:https://api.brainerce.com/api/oauth/customer/callbackA 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
salesChannelIdconnection. 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 avc_*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_error | What happened | What to do |
|---|---|---|
access_denied | Shopper declined consent at the provider | Return to login, no error styling needed |
state_expired | The sign-in sat unfinished past its 10-minute window | Ask them to try again |
state_already_used / invalid_state | Replayed or unrecognised callback | Ask them to start sign-in again |
link_blocked_unverified_password_account | An unverified password account exists and the provider would not vouch either | Send them to email verification; a retry will not help |
provider_already_linked | This customer already has a different account linked for this provider | Point them at profile settings to unlink first |
oauth_account_linked_to_another_customer | That provider account belongs to a different customer | Offer password sign-in instead |
provider_unsupported / provider_disabled | Provider is not implemented, or the merchant turned it off | Hide that button |
server_error | Unexpected platform failure | Generic 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 theauth_codeexchange flow now.
Step 10.4: Manage linked accounts
| Action | Method | Endpoint | Auth |
|---|---|---|---|
| List linked | GET | /customers/me/oauth-connections | Yes |
| Link new | POST | /oauth/{provider}/link | Yes |
| Unlink | DELETE | /oauth/{provider}/link | Yes |
Task 11: Discount banners & promotions
Discount banners (site-wide)
GET /discount-bannersReturns active promotions. Display at top of page.
Product discount badge
GET /products/{productId}/discount-badgeReturns 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
Consolidated cart includes (recommended)
Instead of calling each endpoint separately, fetch everything in a single request:
GET /cart/{cartId}?include=recommendations,upgrades,bundlesReturns 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}/bundlesReturns N-product bundle offers triggered by items in the cart. Each bundle has:
triggerProductId(=productIds[0]): already in cart, activates the offerproductIds: string[]: full bundle composition (length ≥ 2)offeredProducts[]: bundle entries the customer hasn't added yet, each withoriginalPrice+discountedPricetotalOriginalPrice/totalDiscountedPrice: sum acrossofferedProducts
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}/upgradesSuggests premium variants with small price increase.
Checkout order bumps
GET /checkout/{checkoutId}/bumpsReturns 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}/downloadsRequires 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"
}
]| Condition | Display |
|---|---|
downloadsUsed < downloadLimit | Download button |
downloadsUsed >= downloadLimit | "Download limit reached" |
expiresAt in the past | "Download expired" |
downloadLimit is null | Unlimited |
expiresAt is null | Never 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) ornew BrainerceClient({ salesChannelId: 'vc_*' })(vibe-coded mode). Both need a logged-incustomerToken. 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 enrolledThis 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 enginereward.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-onlyStep 15.5: "Recommended for you" (optional, AI)
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 copyreward 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.
| Situation | What to do |
|---|---|
program is null | Store has no loyalty program, so hide loyalty UI |
program.status !== 'ACTIVE' | Program paused or draft, so hide loyalty UI |
enrolled === false | Show an "Join rewards" button → enrollInLoyalty() |
pointsBalance < reward.pointsCost | Disable that reward's redeem button |
(tier?.level ?? -1) < reward.minTierLevel | Reward isn't redeemable yet; show what tier unlocks it |
badges is empty | Hide 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 nocustomerToken(it's meant for a public landing page).
Step 16.1: Give members their share link
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.
| Situation | What to do |
|---|---|
status.referralCode is null | Referrals disabled or customer not enrolled, so hide the share UI |
getReferralInfo → valid: false | Unknown or disabled code, so show normal signup without referral copy |
reward is null on a valid code | Referrer still earns; there's just no referee welcome coupon |
referralWelcomeCoupon is null | None configured, or already used, so hide the coupon banner |
| Same visitor re-registers with a code | Server ignores it. One referral per customer, ever |
profile.birthMonth is missing | No birthday on file yet; render the empty picker |
connection.requireBirthday is true | Require 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 passingsaveCard: trueat 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 onlyIf 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.| Situation | What to do |
|---|---|
getMembershipPlans() is empty | Store sells no memberships, so hide the upsell |
| No saved payment methods | Prompt a saveCard: true checkout before offering the subscribe CTA |
Subscribe throws card_declined | Let the customer pick another saved card |
status === 'PAST_DUE' | Last renewal failed (retried daily; perks paused). Suggest re-subscribing with a working card |
cancelAtPeriodEnd === true | Show "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,storeIdstorefront, adminapiKey). Invc_*mode, build it whencapabilities.features.hasGiftCardsistrue.
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.totaldoes not change, anddiscountAmountdoes 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 providerPOST /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'samountalready has the cards netted off server-side, so charge it as given and never subtractamountAppliedyourself.providerAmountDue === "0.00"→ the cards cover the order. There is no provider charge to make: skip the payment step entirely and callcompleteCheckout(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:completeCheckoutreturns{ orderId }so there is nowaitForOrderpoll, 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 ownpriceDeltais 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):
- Negatives never consume a free slot. They apply at full negative value regardless of
freeQuantity/freeAllocationPolicy. unitPricecannot go below zero. If a downsell stack would push the unit below0, the server rejects withMODIFIER_PRICE_FLOOR_VIOLATED(a generic 400 that leaks no internals). Fail gracefully and ask the customer to remove a downsell.- No nested combos on downsells. A modifier cannot have both a negative
priceDeltaand areferencedProductId; 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:
| Setting | Default | Effect |
|---|---|---|
reviewsEnabled | true | false makes storefront review endpoints return 403 Forbidden. Admin endpoints still work, so existing reviews stay manageable and nothing is lost. |
reviewPhotosEnabled | true | false refuses photo uploads and imageKeys, leaving text reviews working. |
reviewPhotosRequireApproval | false | true 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 persistedThe 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
fieldsto confirm your value landed rather than assuming success means it did. - Values are coerced to the field's type. A
NUMBERfield written as'5'reads back as5. A value that cannot be coerced is a400. - A
requiredfield cannot be cleared. Sendingnullfor one is a400.
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
dataunchanged. translations[locale]missing → returndataunchanged.- 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=60A 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.
| Option | Required | Description |
|---|---|---|
connectionId | yes | Your vc_* connection id |
baseUrl | no | API origin override (self-hosted / staging) |
target | no | Mount element (defaults to document.body) |
onAddToCart | no | ({ 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
| What | Limit |
|---|---|
| Requests per IP | 3 / minute |
| Confirmation emails per address, per store | 1 / 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
stockAlertsEnabledfromgetStoreInfo()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
| What | Limit |
|---|---|
| Requests per IP | 5 / minute |
| Open alerts per address, per store | 25 |
| How long an unfired alert lives | 90 days |
| Alerts sent per request | exactly 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 storeUse 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_HONORfor someone living,IN_MEMORYfor 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:
amountstays what the donor gave,feeCoverAmountis extra,chargeAmountis 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.
Option A: Script tag (recommended)
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, skiptrackEvent()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.comThe 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.comUnder 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.
Consent
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 producedlabelFormat 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 reaches | What the platform does |
|---|---|
| In transit / out for delivery | Appends tracking history, advances shipment status |
| Delivered | Completes the order, sets deliveredAt, sends the completion email |
| Returned / failed / cancelled | Records 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 anapiKey, because it is an admin operation and is not available in storefront (storeId) mode.