Optional Features
Customer accounts, social login, promotions, upsells, downloads, loyalty & rewards, gift cards, wishlist (SDK 3.16.0+), 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": "SecurePass123!",
"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
}Other fields the register call accepts, all optional: phone (a string; E.164 is recommended), acceptsMarketing (boolean, the marketing consent that gates promo email), privacyPolicyAccepted (boolean; the server only records the time it was ticked and never refuses a registration for leaving it out, so enforce a required checkbox on your form), and referralCode (up to 40 characters, a loyalty REF-XXXXXXXX code from a referrer's link; it is checked after the account exists, so a wrong code never fails the registration). firstName and lastName are plain strings with no length rule. The email must be a valid address and is unique per store.
Passwords must be at least 8 characters with an uppercase letter, a lowercase letter, a digit and a symbol; anything weaker is a 400 before the account is looked at, so validate the same rule client-side.
When connection.requireEmailVerification is true, nothing is saved yet. The response then carries "requiresVerification": true, customer.id is the literal string "pending" (not a real id), customer.emailVerified is false, and token is a short-lived pending-registration token that only POST /customers/verify-email accepts. The customer row is created when the code is verified (Step 8.4, sent with this pending token as the bearer), and that call returns the real id and session token. Do not store "pending" as a customer id or treat this token as a login. When verification is off, customer.id is real and token is a normal session token.
⛔ 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 may be kept in localStorage (note that
brainerce_session is the guest cart's write credential, see
Cart ownership proof: its reach ends at one
guest basket, so never log it or share it); the customer token is not storable that way,
and it is the only credential 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": "SecurePass123!"
}Same response. Save token the same way.
A wrong password and an unknown email both answer 401 Invalid email or password, so the form cannot be used to find out who has an account. Three limits sit on this route:
- 5 requests a minute per IP.
- 10 failed sign-ins within 10 minutes for the same email address on one store (counted across all IPs). The tenth failure locks that email for 10 minutes. While it is locked every attempt is refused, even with the correct password, and the refusal is identical to a wrong password:
401Invalid email or password, same body, same response time. Attempts made while locked are not counted, so they cannot extend the lock. The lock ends by itself, and it also ends at once when the customer sets a new password through a password reset. A successful login resets the count. - 100 failed sign-ins per hour from one IP address on one store. This is the only limit that answers
429, and only for that address. It does not apply to the/v1API-key route, where the caller is your server rather than the shopper.
Because a locked email looks exactly like a wrong password, do not tell the shopper "your account is locked". Show the message the API returns, and point them at Forgot password, which is also the way out of a lock.
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);
const cart = await client.syncCartOnLogin(); // moves or merges the guest cart into the accountawait it, then show the cart it resolves with. When the account already had a cart, the guest items are merged into it and the cart you get back is the account's, so its id is not the guest cart's id. Do not call it without await while a cart refresh runs beside it. A refresh that reaches the server first makes it create an empty cart for the customer, and the shopper's items are left in the guest cart ("my cart disappeared when I logged in"). Current SDK versions serialise the two for you, but your own UI still has to re-read the cart once this resolves.
With the cart store, await client.syncCartOnLogin() first, and only then change CartProvider's authKey to the customer's id (or call store.reset('login')): the store drops its queue and loads the merged cart afresh. On logout call client.onLogout() and then let authKey go to null (or store.reset('logout')). Resetting before the client reflects the change loads the old cart again.
Against the raw API:
const sessionToken = localStorage.getItem('brainerce_session');
if (sessionToken) {
try {
// Body is `{ sessionToken }`, the guest cart's own token. A body without
// that key is answered 400, and swallowing that error means the guest cart
// is never merged.
const cart = await brainerceAPI('POST', '/cart/merge', { sessionToken });
// The response is the customer's cart after the merge (a different id than
// the guest cart's when the account already had one). It has no session token.
localStorage.setItem('brainerce_cart_id', cart.id);
localStorage.removeItem('brainerce_session');
} catch (error) {
// 404 = the guest cart is gone (empty, converted or older than 30 days): nothing to merge.
// Anything else (offline, 5xx): keep `brainerce_session` and retry on the next page load.
if (error.statusCode === 404) localStorage.removeItem('brainerce_session');
}
}Requires Authorization: Bearer {token} header.
A storefront with a wishlist (SDK 3.16.0 and newer) does the same for the saved items. Right after syncCartOnLogin() call await client.wishlist.syncOnLogin(): it moves the guest's browser list into the account and returns the account's list. See Wishlist.
Step 8.4: Email verification (if required)
Verify:
POST /customers/verify-email{ "code": "123456" }Requires auth header. Code is 6 digits from email and expires after 10 minutes. A wrong or expired code is a 400 Invalid or expired verification code. After 5 wrong codes within an hour for the same customer (or, before the account exists, the same email address), verification is locked for 30 minutes: every attempt in that time, even with the right code, is a 429 Too many failed verification attempts. Please request a new code and try again later. Besides that lock the route is limited to 10 requests a minute per IP. A correct code clears the count.
Resend:
POST /customers/resend-verificationRequires auth header. Limits: 3 requests a minute per IP, and 3 emails per hour for the same customer. Past the hourly cap the answer is 403 Too many verification requests. Please try again later. while the account is already saved, and 429 Too many verification emails requested. Please wait before requesting another. while it is still a pending registration (customer.id is "pending"). A resend for a pending registration returns a new token in the body: use it as the bearer for the next verify call. A resend for a saved account makes the previous code stop working.
Step 8.5: Password reset
Request:
POST /customers/forgot-password{
"email": "[email protected]"
}The answer is always 200, whether or not the email belongs to a customer, so the form cannot be used to find out who has an account. The reset token in the email is single-use and expires after 15 minutes. A new request cancels the previous unused link. A merchant can also send the same link from the dashboard (the customer record's Send password reset link button); it is the same token and email, so everything on this page applies to it. The merchant never sees the link or the new password. Requests beyond the limits (3 reset emails per hour per account, and 5 requests per 15 minutes per email address) are answered with the same 200 and no email is sent, so tell the shopper to check their inbox and spam folder instead of offering an instant resend button.
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": "NewPass456!"
}newPassword follows the same rule as registration (at least 8 characters, with an uppercase letter, a lowercase letter, a digit and a symbol). Reset-password is limited to 5 requests a minute per IP and forgot-password to 3.
Setting the new password signs the customer out everywhere: every token issued before it stops working, so a stored token will start answering 401 and the shopper must sign in again. It also clears any sign-in lock on that email.
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();
// The SDK keeps the guest session cart on logout: onLogout() only forgets the
// cached customer cart id, so the shopper reverts to guest mode with a working
// cart (same behaviour as the SDK README's "After Logout: Preserve Guest Cart").
client.onLogout();
// On raw REST the equivalent is to keep `brainerce_session` and
// `brainerce_cart_id` in localStorage. They are a cart reference and that one
// guest cart's write credential, not a customer credential. Remove them only if
// you want a shared device to start empty after sign-out; that is a product
// choice, not a security requirement.Task 9: Customer profile & order history
All require Authorization: Bearer {token}.
Step 9.1: Get profile
GET /customers/meThe response is the full profile in both salesChannelId (vc) and storeId modes: id, email, firstName, lastName, phone, hasAccount, emailVerified, acceptsMarketing, role, the complete address book, and timestamps, the same shape PATCH /customers/me returns. Merchant-side annotations on the underlying record (tags, metadata, lifetime spend, external platform ids) never leave this endpoint in either mode.
The response carries birthMonth (1-12) and birthDay (1-31), both absent until the customer sets them (they are returned as a pair or not at all). Seed your profile form from them: this read is the only way to tell "already filled in" from "never asked".
The response includes a role field, a free-form segment the merchant sets from the dashboard or admin API (e.g. "wholesale", "vip", "ambassador"). It's read-only from the storefront: never send it in Step 9.2's update body, because the API rejects unknown fields and the whole request fails with HTTP 400 property role should not exist. 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.8b: Show an invoice/receipt link
Order.invoiceUrl and Order.invoiceNumber are populated when the store's
connected payment provider issues an invoice document. Both are
null until the provider generates the document — this can lag the payment
webhook by a few seconds — and stay null permanently when the store has no
invoicing-capable payment provider connected. Do not treat null as an
error: render nothing (or a "invoice not available yet" note) rather than a
broken link or a retry loop.
{
order.invoiceUrl && (
<a href={order.invoiceUrl} target="_blank" rel="noopener noreferrer">
{t('account.downloadInvoice')} {order.invoiceNumber && `(#${order.invoiceNumber})`}
</a>
);
}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: features.oauthProviders → one button per enabled provider, only while features.oauthRedirectReady is true.
At runtime read the same two facts from the storefront's own address:
GET /oauth/providers
→ { "providers": ["GOOGLE"], "redirectReady": true }
→ { "providers": ["GOOGLE"], "redirectReady": false, "redirectBlockedReason": "no-domain" }redirectReady answers "can a sign-in started from this origin land back here?". It is evaluated from the request's Origin header, so it is per storefront address: localhost on a TEST channel is always ready, while a Lovable/v0/Bolt preview URL is ready only once the merchant has added it to the channel (Dashboard → Sales Channels → Domains). When it is false, render no social-login buttons; every authorize call from that origin will be refused with redirect_not_allowed. In development, log redirectBlockedReason so the builder knows the fix is a channel setting, not code:
no-domain: TEST channel with no domain, and this origin is not one of its allowed origins. Products, cart and checkout work from anywhere on such a channel; a sign-in return does not.origin-not-allowed: the channel has addresses, this origin is not among them.no-channel: storefront (storeId) mode; social login cannot complete there at all.
Step 10.2: Start OAuth
GET /oauth/{provider}/authorize?redirectUrl=https://your-site.com/auth/callbackProvider: GOOGLE, FACEBOOK, or GITHUB, uppercase. The value is matched as-is against the provider enum, so a lowercase google is rejected as an unsupported provider. Use the provider value from Step 10.1's list verbatim.
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. The body carries a stable code:
{
"statusCode": 400,
"code": "redirect_not_allowed",
"message": "redirectUrl is not allowed for this sales channel. ...",
"details": {
"reason": "origin-not-allowed",
"origin": "https://preview--abc.lovable.app",
"channelMode": "TEST",
"hasDomain": false
}
}message and details are for the merchant (which channel setting to change). Never render them to shoppers: show a localized "sign-in with Google is not available right now" and log the details. Step 10.1's redirectReady exists so a shopper never reaches this at all.
In sales-channel mode (vc_*) pass an absolute URL on your registered domain (or a subdomain of it). On a TEST channel the channel's allowedOrigins and any localhost port are accepted too; on a LIVE channel allowedOrigins grants nothing here, only the domain does. A relative path such as /auth/callback works in either mode; it is resolved against the channel's domain on the way back, so the channel needs one registered. A TEST channel with no domain and no allowed origins therefore refuses every non-localhost redirect, absolute or relative, even though it serves the catalogue to any address.
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 callback URL carries only
oauth_success=true,auth_codeandis_new(oroauth_erroranderror_descriptionon failure). The legacy?token=<JWT>&customer_id=...params are not emitted: the platform only adds them when the operator sets theCUSTOMER_OAUTH_LEGACY_TOKEN_REDIRECTflag, which is off, and that path is scheduled for removal. Code that readstokenoff the callback URL never finds one, so exchangeauth_codeas shown above.
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!"
To refresh them without reloading the whole cart: GET /cart/{cartId}/nudges (client.getCartNudges(cartId)), which answers an array of the discount-rule prompts the cart nearly qualifies for, empty when none. It needs the channel's products:read scope and exists on both the sales-channel and storeId surfaces.
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. The automatic group inside recommendations (automaticRecommendations) and the recommendation measurement methods need brainerce 3.17.0 or newer. With the SDK:
const cart = await client.getCart(cartId, {
include: ['recommendations', 'upgrades', 'bundles'],
});
// cart.recommendations — { recommendations: manual cards, automaticRecommendations?: { kind, items } }
// 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 recommendations also have their own call, GET /cart/{cartId}/recommendations?limit=4 (client.getCartRecommendations(cartId, limit)): limit defaults to 4 and is capped at 12, it needs products:read, and an unknown cart is a 404.
The answer is { recommendations, automaticRecommendations? }:
recommendationsis the merchant's manual list, as it always was. It never contains a product already in the cart.automaticRecommendationsis ONE separate group from purchase history,{ kind: 'bought_together' | 'similar', items }. Draw it as its own rail after the manual one, titled fromkind("Frequently bought together" / "Products you may like"). It is absent when nothing qualifies, and it never repeats a cart item or a card of the manual list.limitcaps the manual and automatic cards together; manual cards fill first, so a smalllimitcan leave no room for the automatic group.
Measure recommendations (impressions, clicks and purchases)
Fetching recommendations is not a view, and the SDK never sends an event just because you fetched them. A card is measured only when it was really on screen, and only if it carries a recToken:
// Impression: once per card per page load, after at least 50% of it was visible for 1 second.
// Returns a function that stops watching; a no-op without a token or IntersectionObserver.
useEffect(() => {
if (!ref.current) return;
return client.observeRecommendation(ref.current, card);
}, [card.recToken]);
// Click: on the card's link. Sent with keepalive, so the navigation does not cancel it.
<Link href={`/products/${card.slug}`} onClick={() => void client.trackRecommendationClick(card)} />
// Purchase attribution: pass the card's token on an add that STARTS on a recommendation card.
await client.smartAddToCart({
productId: card.id,
variantId: card.targetVariantId ?? undefined,
quantity: 1,
recommendationToken: card.recToken,
});Over plain HTTP the same measurement is POST /recommendation-events (also /stores/{storeId}/recommendation-events) with { "events": [{ "type": "impression" | "click", "token": "<recToken>", "eventId": "<unique id>" }] }, at most 50 events per request, and the add-to-cart body takes an optional recommendationToken. client.trackRecommendationEvents(events) is the SDK call for it.
- Retries are safe. Every event has an
eventIdthe SDK generates once; a resend carries the same id and is counted once. The SDK retries up to 3 times on a network error, 408, 429 or 5xx, never on another 4xx (a bad or expired token), and it never throws. - Never invent a token. A token is signed by the server for one card on one page; a forged, edited or expired one is rejected, and the add is then simply not attributed.
recommendationTokencredits only the units added in that call, not the rest of the line. An add that does not start on a card carries no token.- What the numbers mean. An impression is one card shown on one page load, not a unique visitor. Purchases are orders that contained a unit added through a recommendation: attributed purchases, not proof the shopper would not have bought anyway. A cancelled or fully refunded order is not counted; a partial refund is.
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 { bumps }: the enabled order bumps whose trigger product is one of the checkout's line items, plus any bump with no trigger product (a store-wide bump, which the API can create but the dashboard cannot). Display during checkout. The list is empty when the merchant switched Checkout Order Bump off for the sales channel (its Upsell & Cross-sell setting), so an empty list is not an error.
Add bump: POST /cart/{cartId}/bump with { "bumpConfigId": "bump_abc", "variantId": "var_abc" }. variantId (max 100 characters) is optional: it is required only when the bump product is variable and the merchant did not lock one variant on the bump, it is ignored when a variant is locked, and it must belong to the bump product (otherwise 400, Variant selection required for this product or Selected variant does not belong to this product). An unknown or disabled bumpConfigId is 404. The bump's discount is applied to the line, and a cart already holding 50 distinct product lines is refused with CART_LINE_LIMIT_REACHED.
Remove bump: DELETE /cart/{cartId}/bump/{bumpConfigId}
Like every write to a guest cart (bundle accept and remove above included), these carry the cart's proof of ownership as ?cartToken=<sessionToken>; the SDK adds it. See Cart ownership proof.
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" }
Both calls carry the guest cart's proof of ownership as ?cartToken=<sessionToken> (the SDK adds it); see Cart ownership proof.
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 + sales-channel modes. Loyalty works with a client created either as
new BrainerceClient({ storeId })(storefront mode) ornew BrainerceClient({ salesChannelId: 'vc_*' })(sales-channel 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. Test-mode (sandbox) orders never earn points and never complete a referral, so a test checkout on a store with a test payment provider leaves the balance unchanged. When points expire, the oldest earned points go first, so points the shopper already spent are never expired out of a newer earn, and a points-based tier ignores earnings from orders that were reversed.
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. A reward coupon can stop working after it was issued. If the order that earned the points is cancelled or fully refunded after the customer spent them, and the coupon has not been used yet, the platform voids it and applyCoupon fails with "Coupon is not active". Show that as "this reward is no longer available". A coupon that was already used is never taken back, and a points balance never goes negative. If the order that used a reward coupon is cancelled or fully refunded, the coupon becomes usable again.
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 + sales-channel 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
});Social sign-up (Google) must pass the code too, or the referral is silently lost: the OAuth round trip never reaches registerCustomer(). Hand the same code to getOAuthAuthorizeUrl(); the backend keeps it with the OAuth state and applies it only if the callback creates the account (or gives a guest-checkout customer their first login). An existing customer signing in is unaffected, so passing it on every click is safe.
const { authorizationUrl } = await client.getOAuthAuthorizeUrl('GOOGLE', {
redirectUrl: `${window.location.origin}/auth/callback`,
referralCode: codeFromQuery, // optional — same checks and rewards as registration
});
window.location.href = authorizationUrl;Clear the stored code after a successful sign-up of either kind, so the next account created in that browser is not attributed to the same referrer.
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 + sales-channel 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) {
// Every failure is a 409. `err.code` is present only when the charge itself
// was declined, and then it is the provider's raw status string (for example
// 'card_declined', 'requires_action' when the bank demands 3D-Secure, which is
// not supported off-session: ask for a different card). Three 409s carry no
// code at all and only a message: already subscribed (to this plan or another
// one, cancel it first), a subscription attempt already in progress, and the
// card's payment provider no longer installed. Branch on `err.code` when it
// exists and fall back to showing `err.message` when it does not.
}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 + sales-channel 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 carry one or more availability windows: a list of weekdays (daysOfWeek, 0 = Sunday to 6 = Saturday) plus a daily startTime and endTime (24-hour HH:mm, the start included and the end excluded). Each window has its own IANA timezone, which is the store's time zone unless the merchant chose another when creating it. A product with no windows is always purchasable. The merchant sets windows through the AI assistant, the admin MCP tools (create_availability_window, list_availability_windows, delete_availability_window) or POST /api/stores/{storeId}/products/{productId}/availability; the dashboard shows them in a read-only Availability Hours column and has no editor. A window cannot be edited, only deleted and created again.
What the server does with them, and what it does not. Outside the windows the cart marks the line isAvailable: false with unavailableReason: "OUT_OF_HOURS" and counts it in hasUnavailableItems and unavailableItemIds, on every cart surface. On a sales-channel or storeId storefront that flag is the only server-side effect: add-to-cart and checkout are not refused because of the hours, so your storefront must gate the buy button on the cart flags itself. The agent checkout (UCP) is the exception: it leaves a line that is outside its hours out of the checkout it builds.
Where the windows are served. The storeId and admin single-product reads (GET /products/:id, GET /products/slug/:slug) return availabilityWindows (id, daysOfWeek, startTime, endTime, timezone) and isAvailableNow (true with no windows, or when the current time falls inside one). The sales-channel (vc_*) product reads and every list endpoint omit both fields, so a sales-channel storefront learns about the hours only from the cart's OUT_OF_HOURS flag after the item is added.
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: {
productId: 'prod_burger',
selections: [
{ modifierGroupId: 'mg_doneness', modifierIds: ['m_medium'] },
{ modifierGroupId: 'mg_cheese', modifierIds: ['m_cheddar'] },
],
},
},
});Each nested entry is an object with the nested productId (required), an optional variantId, its own selections, and, for a deeper level, its own nestedByModifierId.
The product you add to the cart is level 1, so a combo can nest two more levels below it (3 levels in total). The server validates each level independently. A 4th level is refused with a 400 (nested structure exceeds maximum depth of 3; the validator's own code for it is MODIFIER_NESTING_TOO_DEEP).
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 with a400(code: "MODIFIER_VALIDATION_FAILED") whosedetails.errorsholds oneMODIFIER_PRICE_FLOOR_VIOLATEDentry (a generic message 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.
The same rules apply per row. A row whose salePrice is not above 0 and below its
basePrice (or a variant sale price that is not below its price), and a row with
more than 500 variants, is reported as a failed row with the message naming the
field, and the other rows are still imported.
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 in the dashboard under Products → Product settings → Customer Reviews (the gear menu on the products table):
| Setting | Default | Effect |
|---|---|---|
reviewsEnabled | true | false: review routes answer 403 REVIEWS_DISABLED, getStoreInfo().reviewsEnabled is false. Admin endpoints keep working, no review 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. |
What a storefront does when reviewsEnabled is false. Render no reviews UI at all: not the section, not the form, not the star rating on a product card, and no aggregateRating in the product's structured data (the product keeps its stored avgRating and reviewCount, so the rating would describe reviews the shopper cannot see). Read getStoreInfo().reviewsEnabled first; it is present on both /info endpoints and absent on an older backend, where absent means on. The refusal itself carries a stable code at error.details.code === 'REVIEWS_DISABLED', which is how to tell it from the origin check and a suspended store (also 403). A store scaffolded with npx create-brainerce-store does all of this already.
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. So the customer can be told in the store's branding and language without you building an email. Today the email is the only way a value reaches the customer: the storefront order reads (getMyOrders, the guest order lookup, the order by checkout) never return custom field values, and a definition's isPublic flag is stored but no endpoint reads it.
⛔ 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: Orders, the table's ⋮ menu (last column header), Order Settings, Custom Fields. 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.
Four 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. - A
SELECT(Dropdown) field only takes one of its option values. Anything else is a400that names the field and lists the allowed values; match the optionvalueexactly, not itslabel. ASELECTfield with no options accepts no value. Values already stored are never re-checked.
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. DELIVERED and COMPLETED send the same email, and a direct move between them sends no second one: an order that is already DELIVERED or COMPLETED gets no new email, so write the value before the order reaches either status. The full pattern, including what happens when the provider fails, is in Webhooks → Merchant Integration.
Order tags: internal labels on an order
Tags are labels for the store team ("VIP", "gift", "call the customer"). The customer never sees them, no email is sent when they change, and they are not sent to a sales channel. They are separate from the order's notes, which are emailed to the customer when set. Requires an API key with orders:read / orders:write.
Write tags
const order = await client.getOrder('order_123');
const tags = await client.setOrderTags('order_123', [...(order.tags ?? []), 'VIP']);
console.log(tags.tags); // what was ACTUALLY storedsetOrderTags replaces the whole list, so to add or remove one tag, read order.tags first and send the list you want. An empty list clears them. A tag is up to 100 characters and cannot contain a comma; an order carries at most 100. Repeats that differ only in letter case are merged, and a tag takes the spelling the store already uses ("vip" is stored as "VIP" when some order already carries "VIP"). An imported (view-only) order can be tagged too.
Filter by tag
await client.getOrders({ tag: ['VIP', 'Gift'] }); // carries at least one of them
await client.getOrders({ excludeTag: 'B2B' }); // carries none of themThe tag text has to match exactly, letter case included. getOrders() and getOrder() return the tags as order.tags. Order tags are their own list: they are not the product tags or the customer tags.
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 sales-channel 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. Browsers honour that header; a server does not, so the SDK keeps its own short cache for public reads (60 seconds by default, shared by every client in the process, one request for concurrent callers, the last good value if a refresh fails). A server-rendered storefront therefore no longer repeats the same reads on every page view, which is what used to trip the rate limit (HTTP 429). Don't wrap the same SDK call in a second cache without a reason, and keep any extra layer shorter than 5 minutes so merchants' changes still surface. Pass publicCache: false to turn the SDK cache off, publicCache: { ttlMs } to tune it, and call client.clearPublicCache() (for example from a content.* webhook) to show an edit at once. Discount banners are kept for 30 seconds and search suggestions for 10; product reads are never stored, only shared while identical requests are running.
Admin reads and writes live at /api/content/... (not under /v1), take the store as a storeId query parameter, and send no Cache-Control header at all (neither the public max-age nor no-store), so nothing upstream is told to cache them.
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.
Hide the bot on /cart and /checkout. The bubble floats over the page, and on a phone it lands on top of the quantity steppers, the coupon field and the checkout inputs. A covered control cannot be tapped, and shoppers who cannot tap it leave. The storefront, not the dashboard, decides which pages show the bot (the dashboard controls how the bubble looks and behaves, not on which routes it appears): do not mount it on the cart and checkout routes, and destroy it when the shopper navigates into them.
// `bot` is what BrainerceBot.mount() last resolved with (or null); run this on every route change
const hideBot = /^\/(?:[a-z]{2}(?:-[A-Z]{2})?\/)?(cart|checkout)(\/|$)/.test(pathname); // allows a /he or /pt-BR locale prefix
if (hideBot) {
bot?.destroy();
bot = null;
} else if (!bot) bot = await BrainerceBot.mount({ connectionId: 'vc_abc123' });With the zero-code <script> tag, leave the tag out of the cart and checkout page templates. The same rule applies to every other floating widget (chat bubbles, cookie banners, accessibility buttons): hide them there, then run verify-storefront.mjs on a phone viewport to confirm nothing covers a control.
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.
Never open the popup on /cart or /checkout
A popup, a slide-in or an exit-intent modal that opens while the shopper is changing quantities or typing an address covers the cart controls and the checkout fields. On a phone it sits exactly on top of them, and a covered control cannot be tapped. Leave the popup out of those two routes (the footer subscribe bar is fine anywhere it does not float), and never trigger exit-intent there: it fires when the pointer leaves the page, which a shopper does all the time while filling in a form. The same rule covers every floating widget; see mistake #25 in Rules, Common AI mistakes.
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('fr'); // 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 % de réduction sur votre première commande', terms: '…' }GET {baseUrl}/stores/{storeId}/newsletter-benefit?locale=fr
GET {baseUrl}/vc/{connectionId}/newsletter-benefit?locale=fr
→ 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.
Wishlist (saved items)
Available from SDK 3.16.0.
client.wishlistdoes not exist in earlier versions (brainercebelow 3.16.0), and a storefront pinned to an older SDK has no wishlist API to call. Check the installed version before you write any of this, and do not inventaddToWishlist()orgetWishlist()on the client: neither exists.
An optional "save for later" heart on product cards and the product page, a saved-items page, and a count in the header. The starter that npx create-brainerce-store generates already ships all of it (see What the starter ships); read on when you build your own.
Where the list lives (two lists, one merge)
| Shopper | Where the list is stored | Limit |
|---|---|---|
| Guest (not signed in) | The visitor's own browser (localStorage, one list per store). Never reaches Brainerce. | 100 items |
| Registered customer | Brainerce's server, one list per customer. It follows them to another device. | 200 items |
The SDK does not decide which list applies. Your code picks from its own auth state: a guest calls client.wishlist.local.*, a signed-in customer calls client.wishlist.*. When a guest signs in, move the browser list into the account with client.wishlist.syncOnLogin(), in the same place you call syncCartOnLogin().
SDK
// 1. Guest: the heart is clicked
client.wishlist.local.add({ productId: product.id, variantId: selectedVariant?.id });
client.wishlist.local.has({ productId: product.id }); // is it saved?
// 2. Login (after setCustomerToken and syncCartOnLogin)
const { merged, keys } = await client.wishlist.syncOnLogin(); // keys = the account's list now
// 3. Signed in: the heart is clicked
await client.wishlist.add({ productId: product.id }); // { changed, count }
await client.wishlist.remove({ productId: product.id }); // { changed, count }
// 4. The wishlist page: saved ids to live products
const keys = signedIn ? await client.wishlist.keys() : client.wishlist.local.keys();
const products = await client.wishlist.resolve(keys); // same order, deleted products droppedawait syncOnLogin()after every sign-in path (password, email verification, OAuth). It clears the browser list only after the server accepted it, so a failed merge loses nothing and calling it again is safe. It also belongs on page load: a shopper who signed in elsewhere (an OAuth redirect, another tab) arrives with a login cookie and a non-empty browser list.- Never show a server error as an empty wishlist.
keys(),list()andsyncOnLogin()throw on failure. Render an error state with a retry, not "nothing saved". - Never render saved items from the stored ids alone.
resolve(keys)asks the public catalog for the products (current price, stock, translation, region price) and drops products that no longer exist or are no longer published. - Flip the heart at once and roll it back if the write throws. A repeat click is safe: the server answers
changed: false. - At the 200 item cap
addanswers HTTP 400 withcode: 'WISHLIST_FULL'.mergeinstead skips what does not fit and counts it inskipped. The browser list stops at 100 and leaves the list unchanged. - Sign out does not clear the browser list. A signed-out shopper's list is a different list from the account's.
Tracking
client.trackMarketingEvent('add_to_wishlist', { currency, value: price, items: [item] }) sends AddToWishlist to Meta and TikTok. Call it on a new save, not on a removal. See Marketing tags.
REST
Signed-in customers only (Authorization: Bearer {customerToken}); a guest never calls these.
GET {baseUrl}/stores/{storeId}/customers/me/wishlist/keys
GET {baseUrl}/stores/{storeId}/customers/me/wishlist?page=1&limit=20
POST {baseUrl}/stores/{storeId}/customers/me/wishlist { "productId": "clx...", "variantId": "clx..." }
DELETE {baseUrl}/stores/{storeId}/customers/me/wishlist?productId=clx...&variantId=clx...
POST {baseUrl}/stores/{storeId}/customers/me/wishlist/merge { "items": [{ "productId": "clx..." }] }
# Sales-channel mode: the same five routes under /vc/{connectionId}/... (scope customers:auth)| Route | Answers |
|---|---|
keys | { data: [{ id, productId, variantId, createdAt }], count }, newest first, not paginated (the 200 cap is the bound) |
| list | { data: [...], meta: { page, limit, total, totalPages } }, limit at most 100 |
keys, list and every count and total cover only the items whose product is currently visible to the storefront. A product the merchant has since unpublished or hidden stays saved in the customer's list, but it is left out of keys, list and the counts, so a header badge never counts something the shopper cannot open. If the product is shown again, it comes back in the list. Do not treat a saved id missing from keys as an error.
| Route | Answers |
|---|---|
POST | { changed, count }. Idempotent. An unknown, other-store or unpublished product, or a variant of another product, answers 404 |
DELETE | { changed, count }. A JSON body, never an empty 204. variantId omitted removes the product saved as a whole |
merge | { merged, skipped, count }. At most 100 items. A bad item is counted in skipped, never an error |
Writes are throttled (30 per minute for add and remove, 5 per minute for merge). The wishlist routes return ids only; turn them into products with the catalog ids filter below.
Fetch products by id (getProducts({ ids }))
The public catalog read takes an ids filter, in both modes: GET {baseUrl}/stores/{storeId}/products?ids=a,b,c&limit=100 and GET {baseUrl}/vc/{connectionId}/products?ids=a,b,c&limit=100.
- At most 100 distinct ids; more answers 400. Duplicates are ignored.
- The usual catalog rules still apply, so an id that is unknown, belongs to another store or is not published is absent from the answer, not an error.
- An empty
ids=returns no products, never the whole catalog. - It combines with
limit: sendlimit=100when you ask for up to 100 ids, or the default page of 20 cuts the answer short.getProducts({ ids })in the SDK sets this for you.
What the starter ships
npx create-brainerce-store (all three skins) includes a heart on every product card and next to add to cart on the product page, a header icon with the saved count, a /wishlist page (loading, error with a retry, empty state, "add all to cart" that skips sold-out items and items that need an option, and a line telling a guest the list is kept in this browser), a link on the account page, and the merge at login. Needs brainerce 3.16.0 or newer. Do not rebuild it: read useWishlist() and useWishlistPage().
What the merchant sees
Nothing new in the sidebar. In the dashboard, Products has a Saved by column (a click opens a "who saved this" list), Customers has a Saved items column, and the customer's window has a Saved items card. All three count signed-in customers only: a guest's list never leaves their browser, so the numbers undercount real interest. Members who cannot view customers do not see them.
What this does not do
- No guest data on the server. A guest's list exists only in their browser. The merchant never sees it, and clearing browser data erases it.
- No share link and no several lists per customer. One list each.
- No price-drop or back-in-stock emails for saved items. (The back-in-stock alert is a separate feature and does not read the wishlist.)
- No wishlist webhook events, no wishlist field on orders, no per-customer export.
- No setting to switch it off. There is no merchant toggle; leave the heart out of your storefront if you do not want it.
- No
/v1(API key) wishlist routes. The wishlist is a storefront feature (storeIdorsalesChannelIdmode); anapiKeyclient cannot call it.
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 sales-channel 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.
- Fund, optional, only when the store has any: where the money goes. See Funds and dedications.
- Dedication / tribute, optional: the store's own wording from
getDonationOptions()when it defines any ("For the recovery of", "In memory of"), otherwiseIN_HONORfor someone living orIN_MEMORYfor someone who has died. Either way, plus the honoree's name. The API rejects a dedication with nobody named, so make the name required the moment one 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.
- Give monthly, optional (SDK >= 3.11): a one-off / monthly choice, plus "how many months" if you want to offer a fixed number. See Monthly donations.
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',
clientRequestId: attemptId, // made ONCE when the form is shown: crypto.randomUUID()
});Field limits (a violation is a 400): amount and feeCoverAmount take at most two decimals, amount is at least 0.01 and both are at most 1,000,000 in the store currency; donorEmail is a valid address up to 320 characters; donorName, publicName, receiptName and tributeName up to 200; message up to 2000; returnPath up to 512; campaignId, fundId and dedicationTypeId up to 64; donorPhone 6 to 24 characters of digits, spaces, +, (, ) and -; receiptTaxId 4 to 20 letters, digits or -. tributeType is IN_HONOR or IN_MEMORY.
Send a clientRequestId. One random id per form attempt (16-100 letters, digits, - or _; crypto.randomUUID() is ideal), created when the form is shown and resent unchanged on every submit of that attempt. A double-click or a network retry then returns the same donation and payment page instead of opening a second payment. Without it, every call is a new donation. donation.payment is null only when a replay finds the attempt already PAID: there is nothing left to pay, so go straight to the thank-you page.
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 send the donor to the provider's payment page with redirectToDonationPayment (browser only). It checks the address against the same payment-host allowlist checkout uses before navigating, so do not assign window.location to redirectUrl or clientSecret yourself:
if (donation.payment) {
brainerce.redirectToDonationPayment(donation); // optional 2nd arg: { extraPaymentHosts } for a self-hosted provider
} else {
goToThankYou(donation.donationId); // replay of an attempt already PAID
}It throws without navigating when donation.payment is null, when the provider renders inside the page (see below), or when the address is not on the allowlist. donation.payment.redirectUrl is set for every redirect provider (providers that open their own payment page, such as Stripe and PayPal) and null for in-page ones.
When createDonation is refused
The failure is a BrainerceApiError; branch on its status and, where there is one, its code. The donor-facing wording is Brainerce's own — provider text is never returned, because a decline reason tells a card tester which card is live.
| Status | code | Meaning |
|---|---|---|
| 400 | — | Donations are not enabled for this store, or the request is invalid (amount, a tribute or dedication with no name, tributeType and dedicationTypeId both sent). |
| 400 | fund_not_available | The fundId is unknown or the fund was archived. Re-read getDonationOptions() and re-render the picker. |
| 400 | dedication_not_available | The dedicationTypeId is unknown or the merchant retired it. Re-read getDonationOptions(). |
| 400 | missing_return_url | The sales channel has no domain, so a provider that sends the donor back to your site has nowhere to send them. Set a domain on the channel. |
| 400 | missing_notify_url | The payment provider is not fully set up to confirm payments yet. This is a store setup problem, not something the donor can fix. |
| 409 | provider_not_ready | The payment provider is not ready to take payments yet (for example, onboarding unfinished). A provider may send its own code instead. |
| 503 | — | No payment provider is connected, or the provider could not start the payment. The reason is not returned. Safe to retry later, not in a tight loop. |
Show the donor "we can't take donations right now" for every one of these; show the code to yourself. A 400 with missing_return_url is the one you will meet while building: a TEST sales channel may have no domain, and whether that matters depends on the provider — a provider that redirects the donor back needs one, others do not. Give the channel a domain and it goes away.
Three more 409s come only from a reused clientRequestId, and they are not "closed" errors:
| Status | code | What to do |
|---|---|---|
| 409 | idempotency_key_mismatch | The same id was sent with a different amount, fee cover, email, channel, campaign, fund, returnPath, one-off / monthly choice or number of monthly gifts. A changed form is a new attempt: make a new id. |
| 409 | donation_attempt_closed | That attempt already failed or was cancelled. Make a new id for the next attempt. |
| 409 | donation_in_progress | The first submit is still opening the payment (the body carries donationId). Wait briefly and submit again with the same id. |
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.
getDonation() also returns monthly (true on the first gift of a monthly donation: thank the donor for giving monthly) and isTest (true when the store's payment provider was in test mode: no real money moved, so do not present it as a real gift).
Monthly donations
SDK >= 3.11. A donor can start a monthly donation themselves. Add recurring to the same createDonation call; nothing else about the flow changes:
const donation = await brainerce.createDonation({
amount: 180,
donorEmail: '[email protected]',
campaignId: campaign.id, // optional, as on any gift
recurring: { interval: 'MONTH', maxCycles: 12 }, // "12 x 180"; omit maxCycles to give until cancelled
returnPath: '/thank-you',
clientRequestId: attemptId,
});
if (donation.payment) brainerce.redirectToDonationPayment(donation);- The first gift is charged now, on the provider's own page through
redirectToDonationPayment, exactly like a one-off gift, and the card is saved. Wait forPAIDas above. - The same gift then repeats every month on the store's standing orders, with the same campaign, fund, dedication and receipt details. The schedule is created when the first payment settles, not when
createDonationresolves. maxCyclesis the total number of gifts, including the first: "12 x 180" is12. At least 2, at most 120. Omit it and the gift repeats until it is cancelled.- Monthly only.
intervalaccepts'MONTH'and nothing else: no weekly or yearly giving. - A covered fee applies to the first gift only. The monthly gifts are
amountalone. - Thank the donor for giving monthly when the read-back has
monthly: true.
Two more refusals, both returned before any charge, so the donor has paid nothing:
| Status | code | Meaning |
|---|---|---|
| 409 | recurring_not_supported | The store's payment provider cannot charge a saved card, so it cannot take monthly donations. |
| 409 | provider_mode_unknown | The payment provider is not fully set up (its live / test mode is not declared yet). |
There is no flag to read in advance. Offer the monthly choice; on either code, tell the donor monthly giving is not available right now and offer a one-off gift instead.
If the provider returns no saved card, the first gift stays paid (charged once, never twice) and the donation is flagged "Monthly setup failed" in the merchant's dashboard so they can follow up. The donor's thank-you page is unaffected.
Donor self-service: the management link
SDK >= 3.11. Donors manage a monthly donation themselves, from a page Brainerce hosts. You do not build that page. The first email a monthly donor gets is "Your monthly donation is set up" (instead of the usual thank-you), and it carries a personal link; so does every later monthly gift's thank-you, and every failed-payment email. On that page, in the store's language (Hebrew or English), the donor sees their monthly donations (amount, gifts so far and of how many, next date, campaign / fund / dedication) and can pause (one Pause button; giving does not restart by itself, the donor resumes it from the same page), resume a pause they chose, or stop (with a confirm step).
The link is a secret: it works for 30 days, only its hash is stored, and asking for a new one turns the old one off. The one thing your storefront adds is a small "Manage my monthly donation" form on the donation page, for a donor who lost the link:
import { BrainerceError } from 'brainerce';
async function onRequestLink(email: string) {
try {
await client.requestDonorManageLink(email); // always { accepted: true }
show('If that email gives monthly, a link is on its way.');
} catch (err) {
if (err instanceof BrainerceError && err.statusCode === 429) {
show('Please wait a minute and try again.');
} else {
show("We couldn't send the link right now.");
}
}
}It resolves { accepted: true } whether or not that email has a monthly donation, so it reveals nothing about who gives; word the confirmation the same way. It is rate limited to 3 requests per minute. Storefront and sales-channel modes only (POST /api/stores/:storeId/donor-manage/request-link or /api/vc/:connectionId/donor-manage/request-link); an admin apiKey throws.
Card update: on the hosted page, Stripe only. Do not build one. When the store's payment provider can save a card without a charge (today only Stripe), the hosted page shows each live monthly donation's card ("Card: visa •••• 4242") and an Update card button. It opens the provider's own page (Stripe Checkout in setup mode), where nothing is charged; the donor then lands on a Brainerce "Card saved" page with no link back. Once the provider confirms, the new card is the one the monthly donation charges. A donation paused after failed payments (or between retries) resumes by itself and the missed gift is charged within minutes (a failure there is retried like any monthly payment); an active one only changes card and is charged on its usual date; a pause the donor or the merchant chose stays. With every other provider there is no card, no button, and no way for the donor to change the card. No SDK method is involved and your storefront adds nothing: do not build your own card-update screen. The merchant has no card-change control either.
When a monthly charge fails, the donor is emailed (with the link) after each try; with Stripe the email tells them to update their card from the link, otherwise to contact the organisation. After the 3rd failed try the monthly donation is paused, not cancelled, and the merchant is emailed. The merchant resumes it, retrying the same card, from the Donations page in the dashboard (open the donation; it does not need the Standing orders module) or through the AI assistant, and can pause or stop it there too.
A changed choice is a new attempt. A clientRequestId cannot switch between one-off and monthly, or change the number of gifts: the same id with a different choice is a 409 idempotency_key_mismatch.
Test mode. A gift (one-off or monthly) taken while the store's payment provider is in test mode comes back with isTest: true, is shown with a "Test" badge in the dashboard, and never counts toward public campaign progress. The merchant's own totals leave it out once the provider takes real money; while the provider is still in test mode they count test gifts, so a store trying its donation page does not see zeros, and say so with includesTestGifts: true. A monthly donation started in test mode is charged only while the provider stays in test mode: if the provider goes live, it is suspended and never charges a real card. A live monthly donation is likewise suspended if the provider is switched back to test.
Donation campaigns
SDK >= 3.11. A donation campaign is a fundraising drive the merchant sets up ("Building fund 2026"): a name, a description, an image, an optional goal, optional start and end dates, its own preset amounts, whether a free amount is allowed, and min/max limits. ⛔ It is not an email campaign. Always say "donation campaign" in your code and copy, so nobody (human or agent) confuses the two.
A campaign page is optional. The general donation form above keeps working without one; a gift without campaignId is a general gift.
const { data: campaigns } = await brainerce.getDonationCampaigns({ locale: 'he' }); // active + paused, newest first, max 50
const campaign = await brainerce.getDonationCampaign('building-fund', { locale: 'he' });Both are storefront calls (salesChannelId or storeId mode). A store with donations closed has no public campaigns: the list is empty and a single campaign is a 404 campaign_not_found, as is a draft, archived or unknown slug. Responses are cached for about 30 seconds, so a total can lag a fresh gift briefly.
Render the page from the campaign, not from your own constants:
name,description(plain text, never render it as HTML),image.- Progress. With a
goalAmount, showraisedAmountagainst it and a bar atpercentOfGoal. Without one (goalAmountandpercentOfGoalarenull), show the total raised and no bar.percentOfGoalis uncapped: reaching the goal does not close the campaign, it keeps taking gifts and the figure goes past 100, so clamp the bar width, not the number. raisedAmountis gross gifts: a fee the donor chose to cover is not counted and refunds are not subtracted.donationCountcounts gifts, not people (a monthly donor counts once per month), so label it "gifts", not "donors". Test-mode gifts are not counted. A monthly gift keeps counting toward its campaign every month, even after the campaign'sendsAt: it was pledged while the campaign ran.- The form only when
state === 'active'. The four states:
state | What to show |
|---|---|
upcoming | The campaign and "opens on startsAt". No form. |
active | The form. |
paused | The campaign and its progress, "not taking donations right now". No form. |
ended | The final total. No form. It reopens on its own if the merchant moves endsAt later. |
- Amounts. Offer
suggestedAmountsas the presets and show the free amount field only ifallowCustomAmount. EnforceminAmount/maxAmountclient-side for a better form; the server enforces them anyway.
if (campaign.state === 'active') {
const donation = await brainerce.createDonation({
amount: 180,
donorEmail: '[email protected]',
campaignId: campaign.id,
returnPath: `/campaigns/${campaign.slug}/thank-you`,
clientRequestId: attemptId,
});
if (donation.payment) brainerce.redirectToDonationPayment(donation);
}Everything else about the form (fee cover, dedication, anonymity, clientRequestId, waiting for PAID) is exactly as above. A campaign may name a default fund in campaign.fundId: preselect it in the fund picker (see Funds and dedications), and let the donor choose another. When campaignId is passed, createDonation can also be refused with:
| Status | code | Meaning |
|---|---|---|
| 404 | campaign_not_found | Unknown id, or a draft or archived campaign. |
| 409 | campaign_not_started | Before startsAt. |
| 409 | campaign_paused | The merchant paused it. |
| 409 | campaign_ended | Past endsAt. |
| 400 | amount_below_minimum | Below minAmount. |
| 400 | amount_above_maximum | Above maxAmount. |
| 400 | amount_not_offered | allowCustomAmount is off and the amount is not one of suggestedAmounts. |
On a 409, re-read the campaign and re-render: its state changed while the page was open. The campaign is checked only when a new attempt is created; a replay of an already accepted attempt (same clientRequestId) still resolves after the campaign ends, so a donor who double-clicked is not stranded.
Merchants create and manage campaigns in the Donations area of the dashboard, or through the AI assistant and the admin MCP tools (create_donation_campaign, update_donation_campaign, ...). Statuses there are DRAFT (hidden), ACTIVE, PAUSED (shown, not taking gifts) and ARCHIVED (hidden, kept for history); there is no delete. The donation.paid / donation.refunded webhook payload carries campaignId (null for a general gift).
Funds and dedications
SDK >= 3.11. Two lists the merchant defines, read in one call:
- A fund is a designation: where the money goes (scholarships, the building, one of several institutions under the same organisation). It is not a donation campaign (how money is raised over a period) and not a sales channel (which site the gift came from). A gift to a campaign has a fund too: the campaign's default, or another the donor picked.
- A dedication type is the store's own wording for a tribute ("For the recovery of", "In memory of", "For the success of"), in the merchant's order and translatable. Never hard-code these phrases: the organisation chose them, and they differ by community and language.
// getDonationOptions(options?: { locale?: string }): Promise<DonationOptions>
const { funds, dedicationTypes, receiptTaxIdRequired } = await brainerce.getDonationOptions({
locale: 'he',
});
// funds: [{ id, slug, name, description }] description is plain text, never HTML
// dedicationTypes: [{ id, label }] e.g. "For the recovery of"
// receiptTaxIdRequired: boolean true = show the ID field as REQUIREDBoth lists are empty when the store offers none, or when donations are closed: render no picker for an empty list, never a picker with nothing in it. Without dedication types, fall back to the IN_HONOR / IN_MEMORY tribute, or offer none. The response is cached for about a minute, so a fund the merchant just added can take that long to appear. This call is not under the 5-per-minute donation cap.
The form pattern:
- Fund picker only when
funds.length > 0. On a campaign page, preselectcampaign.fundIdif it appears infunds. SendfundIdonly when the donor actually chose one; a gift to a campaign with nofundIdtakes the campaign's default, and a general gift with none has no fund. - Dedication select + honoree name. When the donor picks a dedication, send its
idasdedicationTypeIdand the honoree intributeName(required). SenddedicationTypeIdor the legacytributeType, never both: both is a 400. - Receipt details:
receiptName(the receipt is made out to someone else: a spouse, a company),donorPhone, andreceiptTaxId(the donor's national ID or company number, for a tax receipt; 4-20 letters, digits or-). Optional, except that the ID field must be shown as required whengetDonationOptions()returnsreceiptTaxIdRequired: true(see Tax receipts). publicName, optional: the name the donor allows on public surfaces (a donor wall, the thank-you page). It defaults todonorNameand is ignored on an anonymous gift.
const donation = await brainerce.createDonation({
amount: 180,
donorEmail: '[email protected]',
donorName: 'Sarah Cohen',
publicName: 'The Cohen family',
fundId: chosenFund?.id, // omit when the donor did not choose
dedicationTypeId: chosenDedication.id,
tributeName: 'Avraham ben Sarah',
receiptName: 'Cohen Holdings Ltd',
receiptTaxId: '512345678',
donorPhone: '+972501234567',
returnPath: '/thank-you',
clientRequestId: attemptId,
});⛔ receiptTaxId is a national ID. Never echo it back. It is stored encrypted, no public call ever returns it, and the organisation sees only its last four characters. Do the same on your side: do not keep it in localStorage, put it in a URL, send it to analytics, or show it on the thank-you page.
Receipt details reach the receipt, or the organisation's own system. When the store issues tax receipts (below), the full receiptTaxId goes to the receipt app and nowhere else. Either way the details are recorded on the donation and passed to the organisation's own systems in the donation.* webhook (receiptName, donorPhone, receiptTaxIdLast4, never the full id).
Tax receipts: receiptTaxIdRequired
A store can issue donation tax receipts through a connected receipt app. The merchant turns this on in the dashboard (Donations page, Receipts tab); the receipt is issued server-side after the gift is paid, so the storefront never calls anything to issue one. Its only job is the ID field:
getDonationOptions().receiptTaxIdRequired === true: receipts are on and the merchant asks for the donor's ID (the Tax Authority requires it on every receipt). Render the ID field as required, labelled as the ID or company number for the tax receipt, and send it asreceiptTaxId.false: receipts are off, or the merchant collects the ID another way. Keep the field optional (or leave it out).- No 400 for a missing ID. A gift without
receiptTaxIdis still accepted and paid; it just gets no receipt until the merchant adds the ID by hand. So the required marker is a form rule, not a server refusal: never block a donor at the server's expense, and never retry the submit because of it. - A test donation never gets a receipt, and none is issued while the store's receipt app is in test mode.
const { receiptTaxIdRequired } = await brainerce.getDonationOptions({ locale });
<label>
{receiptTaxIdRequired ? 'ID number (required for your tax receipt)' : 'ID number (optional)'}
<input
name="receiptTaxId"
inputMode="numeric"
required={receiptTaxIdRequired}
autoComplete="off"
/>
</label>;A backend that predates receipts omits the field: treat undefined as false.
Read back. getDonation() returns dedicationLabel, the wording as given (a snapshot, so renaming or retiring a type never changes a past gift), and donorName now prefers publicName. The snapshot is in the store's default language: on a localized thank-you page, show the label the donor picked from getDonationOptions({ locale }).
A changed fund is a new attempt. The fund is part of what a clientRequestId replays, so the same id with a different fundId is a 409 idempotency_key_mismatch. The dedication and receipt details are not: a replay keeps what the first submit sent.
Merchants manage funds and dedication types in the Funds tab of the Donations page, or through the AI assistant and the admin MCP tools (list_donation_funds, create_donation_fund, update_donation_fund, list_dedication_types, create_dedication_type, update_dedication_type, ...). Funds are ACTIVE or ARCHIVED; there is no delete.
What is not there
Say these out loud to the merchant rather than letting them assume:
- Tax receipts only through a connected receipt app, and only from the dashboard. With a receipt app installed and receipts turned on, the server issues a tax receipt for each paid gift (see Tax receipts). There is no SDK method to issue, list or retry a receipt: those are dashboard actions. Without a receipt app, Brainerce issues no receipt and files nothing with any tax authority, and it never registers the organisation with the Tax Authority.
- Donors can update the card on a monthly donation only when the store's provider is Stripe, and only on the Brainerce-hosted page their emailed link opens (Donor self-service), next to pause, resume and stop. With any other provider there is no card-update flow. There is no SDK method for it on any provider; do not build a card-update screen in the storefront. Monthly is the only interval.
- A fund is a designation, not a separate account. A gift carries one fund (or none), and the merchant sees what each fund raised, but the money still lands in the store's one payment provider account. A gift is never split across several funds.
- Redirect payment providers only. Donations work with providers that open their own payment page (redirect providers; Stripe and PayPal are examples). A store on a provider that renders inside the page cannot take donations from the storefront yet:
payment.redirectUrlisnullandredirectToDonationPaymentthrows before navigating. Checkpayment.redirectUrlrather than a provider name. - Admin API keys cannot start a donation. The donation, donation-campaign and
getDonationOptionsmethods are sales-channel (salesChannelId) or storefront (storeId) mode only.
Events
A one-off event with a seat quota, where a guest registers without an account. Requires SDK 3.15.0 or newer (the first release that exports getStoreEvents).
An event is either free (event.isFree, confirmed on the spot) or paid (event.price per seat, in event.currency). Build the payment step only when isFree is false; a free event has none. A paid event can be published only when the store has a payment provider connected that opens its own hosted payment page (and payments are not paused), so a store without one never shows you a paid event.
An event is not a product, a checkout or an order
It has no cart, no shipping and no order, and it never appears in the merchant's orders. Do not model it as a product "Ticket" with a quantity: the seat quota, the registration number, the confirmation email and the attendee list all belong to the event, and a product would lose every one of them.
Gating
There is no store flag to read. getStoreEvents() returns { data: [] } while the merchant has no published event (or has the module off), so render nothing for an empty list. Never hard-code an "Events" page for a store that has none.
Read
const { data: events } = await brainerce.getStoreEvents({ locale: 'he' }); // at most 50, soonest first
const event = await brainerce.getStoreEvent('open-evening', { locale: 'he' }); // id or slug
const { state, seatsRemaining } = await brainerce.getStoreEventAvailability('open-evening');stateis computed, not stored:open,full,closed,not_open_yet,past,cancelled. Onlyopentakes registrations. Show every other state with the event and no form. Acancelledevent stays readable so an old link says so.seatsRemainingisnullfor an unlimited event. Counts seats, not registrations.- Render
startsAt/endsAtinevent.timezone, never the visitor's zone:new Intl.DateTimeFormat(locale, { timeZone: event.timezone, ... }). A 19:00 event in Jerusalem reads 19:00 for every visitor. addressis public. The meeting link of an online event is deliberately not on the event: it is for registered participants only and arrives in the confirmation email. Never try to show it on the event page.phonePolicy(NONE|OPTIONAL|REQUIRED),requireAttendeeNamesandmaxPerRegistrationdescribe what the form must ask for.
Register
const attemptId = crypto.randomUUID(); // once, when the form is shown
const registration = await brainerce.registerForStoreEvent('open-evening', {
clientRequestId: attemptId,
contactName: 'Dana Levi',
contactEmail: '[email protected]',
seats: 2, // the party size: ONE registration, TWO seats
attendees: [{ name: 'Dana Levi' }, { name: 'Avi Levi' }], // when requireAttendeeNames
});
// registration.registrationNumber, e.g. "R-0007"; registration.replayed- Field limits (a violation is a
400):contactName1 to 120 characters;contactEmaila valid address up to 254;contactPhone5 to 32 characters of digits, spaces,+,(,),-and., required only whenphonePolicyisREQUIRED;seatsa whole number from 1 to 50 and at mostevent.maxPerRegistration;attendeesone entry per seat (the count must equalseats) withname1 to 120 characters, required only whenrequireAttendeeNamesis true;clientRequestId8 to 100 characters;localeup to 16 characters (the language of the confirmation email, the store language when omitted);returnPath(paid events only) up to 512 characters and a path starting with a single/, never a URL, because the host always comes from the sales channel. - A party of three is one registration with
seats: 3, not three registrations. clientRequestId: the SDK generates one per call when you leave it out, so a double click inside one call cannot take two sets of seats. Pass your own, made once when the form is shown and resent on every submit, to also make a retry after a network error return the same registration (replayed: true). Calling the HTTP route directly, you must send it yourself: it is optional on the wire, and without it a double submit takes two sets of seats.- Rejections carry a stable
codeand are409unless noted:event_full,not_enough_seats(withseatsRemaining),event_past,registration_closed,registration_not_open,event_cancelled,idempotency_key_reuse(the sameclientRequestIdcarrying a different registration), and400fortoo_many_seats,phone_required,attendee_names_required,attendee_count_mismatch. Refresh the availability and tell the visitor; do not retry blindly. - Rate limit: 5 registrations a minute per client. It is an unauthenticated route that takes seats.
- Free: confirmed means confirmed. There is no pending state for a free event. The confirmation email is sent after the response, and a failure to send it never cancels the registration.
Paid events: hold, pay, confirm
A paid registration is not confirmed by the registration call. The call HOLDS the seats for a limited time (holdExpiresAt, set by the merchant, 15 minutes by default) and returns a payment to complete:
const reg = await brainerce.registerForStoreEvent('gala', {
clientRequestId: attemptId, // REQUIRED for a paid event, see below
contactName,
contactEmail,
seats: 2,
returnPath: '/events/thank-you', // where the payer comes back to after paying
});
// reg.status === 'HELD', reg.amount === '100.00', reg.payment is the payment to complete
brainerce.redirectToStoreEventPayment(reg); // redirect providers (Stripe, PayPal, ...)On /events/thank-you, poll getStoreEventRegistrationStatus(registrationId) every few seconds (it is limited to 30 a minute) until it reads a final status, and say "you are registered" only on CONFIRMED:
status | What happened | What to show |
|---|---|---|
HELD | Seats held, payment not verified yet | "Confirming your payment..." and keep polling |
CONFIRMED | The server verified the payment and gave a seat | The registration number and "a confirmation email is on its way" |
EXPIRED | The hold ran out, or the payment failed | "Your payment did not go through" and a way to try again |
NEEDS_REFUND | The payment arrived after the hold ran out and no seat was left | "We could not give you a seat. The organiser will refund you." Do not say they are registered |
CANCELLED | The organiser removed the registration, or cancelled the event | Say so |
Rules that decide whether your integration is correct:
- Never confirm from the browser. The payment page returning the payer to your site proves nothing. The registration is confirmed only when the server verifies the payment (the provider's webhook, or the server asking the provider), and the confirmation email goes out then.
clientRequestIdis required for a paid event (400 client_request_id_requiredwithout it). A double click without a key would open two payments for one person. The SDK generates one per call, but the same key must be resent when you retry the same attempt (replayed: truereturns the same payment page), and a new attempt needs a new key.- A hold is not a promise. Seats are held only until
holdExpiresAt. Show a countdown if you like; after it, a replay answers409 registration_hold_expiredand the payer starts again. - The amount is frozen when the registration is created (price x seats). Changing the event's price later never re-prices a hold that is already at the provider.
- A payment that arrives late is kept, never lost. If the hold had run out, the server re-seats the payer when there is still room; otherwise it keeps the money and flags the registration
NEEDS_REFUNDfor the organiser to refund. There is no automatic refund. - Provider support: events work with providers that open their own payment page (redirect providers; Stripe and PayPal are examples). Providers that render the card form inside your page are not supported for events: publishing a paid event is refused for a store whose provider is one, and a registration that reaches such a provider is refused with
503 payment_page_not_hostedand its seats are released in the same call (nothing stays held).redirectToStoreEventPaymentstill throws a clear error, without navigating, ifpayment.redirectUrlis evernull. - Extra rejections for a paid event:
503 payment_provider_missing(the store has no payment provider),503 payment_page_not_hosted(its provider has no hosted payment page, and no seats are held),409 registration_hold_expired,409 registration_in_progress(the first submit is still opening its payment),409 registration_attempt_closed. When the provider refuses to open the payment, the seats are released at once and the answer is400 missing_return_url(the sales channel has no domain, so the payer cannot be sent back),400 missing_notify_url(the provider is not fully set up to confirm payments),409 provider_not_ready(or the provider's owncode), or a plain503("Could not start the payment"; the reason is never returned). Show the visitor a generic "we can't take payment right now" for all of them. - TEST mode: a registration taken while the merchant's payment provider is in test mode is a TEST registration (
statusread showsisTest: true). No real money moved. It still holds real seats until the organiser removes it, so tell the payer plainly whenisTestis true.
The guest's own registration
const mine = await brainerce.getStoreEventRegistration(token); // token from the email link
await brainerce.requestStoreEventRegistrationLink({ email, registrationNumber }); // always { accepted: true }The email link opens a page hosted by Brainerce, so a storefront does not need to build one. getStoreEventRegistration is for a storefront that wants its own "my registration" page: 404 registration_not_found for an unknown, expired or wrong-store token (always the same answer). The participants-only event.onlineUrl appears on this read, and only for a CONFIRMED registration. requestStoreEventRegistrationLink answers { accepted: true } whether or not anything matched, so it cannot be used to learn who registered; each new link replaces the previous one. Rate limited to 3 a minute.
What is not here
No waiting list, no QR codes or PDF tickets, no ticket types, no recurring events, no reminders, no editable email, no coupon or gift card on a seat. An event is never an order: it does not appear in the merchant's orders or sales figures. The confirmation email is a fixed, translated template in the store's name and colours, and registering is not consent to marketing email.
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>:
<script defer src="https://api.brainerce.com/t.js" data-channel="vc_abc123"></script>t.js reads data-channel only, so it needs a vc_* sales channel id. A storefront connected by plain storeId has no channel for the pixel to attribute to; use Option B, client.trackEvent(), there instead.
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.
Beacon fields (POST /api/vc/{salesChannelId}/events, answers 204, limited to 120 requests a minute per IP; the storeId surface has POST /api/stores/{storeId}/events). All fields are optional: eventType (pageview default, or engagement), path and referrer (up to 2048 characters), utmSource, utmMedium and utmCampaign (up to 128), screenWidth (integer, at most 20000), lang (up to 35 characters) and engagedMs (integer, at most 1,800,000, for engagement). The request must come from a browser (the route checks the Origin like the rest of the storefront API).
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.
Attribution fields on cart and checkout calls
The SDK attaches marketing attribution to a few write calls by itself, so a storefront using it does nothing. They are listed for storefronts that call the REST routes directly; every one is optional and a missing one is never an error.
- Cart create and add-to-cart:
analyticsClientIdandanalyticsSessionId(the GA4client_idandsession_idfromgtag('get', ...), up to 100 characters each, written onto the cart if not already set), so the server-side purchase event stitches to the browser session. - Checkout customer and shipping-address calls: the same two ids, plus the last non-direct touch the SDK captured:
trafficReferrerHost(up to 253),trafficUtmSource,trafficUtmMedium,trafficUtmCampaign,trafficUtmTerm,trafficUtmContent(up to 150 each),trafficGclid,trafficGbraid,trafficWbraid(up to 128 each; Google Ads click ids, write-only and never returned by a public read),trafficLandingPath(a path, never a query string, up to 300),trafficCapturedAt(ISO 8601) andconsentVersion(up to 40 characters, which consent regime was active, for examplegcm-v2). The same calls takeanalyticsConsentGrantedandadsUserDataConsentGranted(booleans fromclient.updateConsent()): leave them out until a consent signal exists, because an omitted value is not the same asfalse.
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 on a vc_* sales channel (the storeId storefront /info has no tracking block, because the tags are configured per channel, so initTracking(undefined) there is a no-op), 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
const commerce = buildGa4CommercePayload(order); // net value + tax/shipping split — see below
client.trackMarketingEvent('purchase', {
transactionId: order.id,
currency: commerce.currency,
value: commerce.value,
shipping: commerce.shipping,
tax: commerce.tax,
items: order.items.map((i) => ({
itemId: i.sku, // Meta/TikTok pixel id
productId: i.productId, // GA4/GTM id is built FROM these, not itemId — see below
variantId: i.variantId,
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.
Rules that break things silently
itemId is NOT the GA4/GTM item id. itemId (the SKU-preferring id) is only for Meta/TikTok pixels. For GA4/GTM, trackMarketingEvent builds item_id itself from productId/variantId (prod_<productId> / var_<variantId>) — matching what Brainerce actually publishes to the Google Merchant Center feed's offerId. Send the SKU or a bare unprefixed id instead and Shopping ads / dynamic remarketing item matching stops working with no error anywhere: the events arrive, GA4 reports an id its Merchant Center feed has never seen, and the audience never builds. Always pass productId/variantId alongside itemId.
value must be net item revenue, not the order total. GA4 expects tax/shipping as separate params alongside value — sending the grand total as value while also sending tax/shipping double-counts them and inflates ROAS. Use buildGa4CommercePayload(order) (exported from brainerce) rather than order.totalAmount.
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. initTracking() handles the Consent Mode v2 plumbing on its own: it defaults to denied for the EEA, UK and Switzerland (granted elsewhere, so nothing changes for existing merchants outside those regions) before any tag loads, including one loaded through GTM.
It ships no banner UI. A store selling into a default-denied region gets zero analytics/ads data until you call client.updateConsent({ analytics: true, ads: true }) from your own banner (or a GTM-based CMP's callback). The choice persists and is auto-attached to setCheckoutCustomer/setShippingAddress as two SEPARATE fields, analyticsConsentGranted for the analytics purpose and adsUserDataConsentGranted for the ads purpose, so the backend's server-side GA4 events AND any Google Ads Enhanced Conversions upload each honor a decline on their own purpose. Never assume one implies the other: a shopper can grant analytics while declining ads.
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.
Google Customer Reviews (optional, order-confirmation survey)
This is Google's own verified-buyer survey opt-in module that feeds the seller rating shown next to a merchant's ads/search listing. It is not Maps/Business reviews, and it is not Brainerce's own storefront product-review feature (star ratings on product pages) — those are unrelated systems.
If the merchant has connected a Merchant ID and opted in, call
getGoogleCustomerReviewsSurvey(checkoutId) on the order-confirmation page and
render Google's opt-in module only when enabled is true:
const survey = await client.getGoogleCustomerReviewsSurvey(checkoutId);
if (survey.enabled) {
// Google's script must be embedded directly on this page — Google does not
// support delivering this integration through Google Tag Manager.
// <script src="https://apis.google.com/js/platform.js?onload=renderOptIn" async defer></script>
// Then define window.renderOptIn to call:
// gapi.surveyoptin.render({
// merchant_id: survey.merchantId,
// order_id: survey.orderId,
// email: survey.email,
// delivery_country: survey.deliveryCountry,
// estimated_delivery_date: survey.estimatedDeliveryDate,
// ...(survey.lang ? { lang: survey.lang } : {}),
// });
}enabled: false means the survey should not render for this specific order —
the merchant hasn't opted in, no Merchant ID is connected, or no honest
delivery-date estimate could be derived for this order (never fabricated).
Treat it as "nothing to render," not an error, and never make placing the
order conditional on this call succeeding.
⛔ survey.languageSupported === false does not mean the widget is broken —
it means your store's configured language is not on Google's own supported-
language list for this widget. Do not assume it renders in Hebrew (or any
other storefront language) just because the rest of your UI does — Hebrew is
a real example of a language this specific Google widget does not support.
The widget still renders in this case; only the wording changes.
Wrap both the fetch and the script injection in try/catch. A Google-side or network failure here must never break order confirmation itself.
Turning this on is not a guarantee that a seller-rating badge or star rating will appear in the merchant's ads — that depends entirely on Google's own program eligibility and survey-response volume.
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); // the shipping address must already be set
// 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'
});
if (label.labelUrl) console.log(label.labelUrl); // Label file URL — absent for an
// on-demand courier app (no printable label; track via `trackingNumber` instead)
console.log(label.trackingNumber); // e.g. '9400111899223397662488'
console.log(label.carrier); // e.g. 'USPS'
console.log(label.labelFormat); // what the carrier actually produced, if anythinglabelFormat 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 — and check labelUrl for a value at all before using it, since not every carrier produces a label.
The label purchase is charged directly to the merchant's carrier account. Brainerce stores trackingNumber and labelUrl on the Shipment record, surfaced through getOrderShipments() — they are not exposed on the buyer-facing order history.
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.
Three 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.
A carrier with no rate API has no price. Some carriers (HFD, for example) invoice the merchant under their own contract and publish no rates. Such a carrier is listed last in getOrderShippingRates() with quoted: false: its price is a placeholder, so never show or sum it, and its id buys a label without a quote. Rate ids also name the shipping app that issued them, because a store can install more than one, so pass them back whole.
SDK mode required:
createShippingLabel()requires anapiKey, because it is an admin operation and is not available in storefront (storeId) mode.